/* * Copyright (C) 2008 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.email.provider; import com.android.email.Email; import com.android.email.mail.internet.MimeUtility; import com.android.email.provider.EmailContent.Attachment; import com.android.email.provider.EmailContent.AttachmentColumns; import com.android.email.provider.EmailContent.Message; import com.android.email.provider.EmailContent.MessageColumns; import android.content.ContentProvider; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.MatrixCursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Binder; import android.os.ParcelFileDescriptor; import android.text.TextUtils; import android.util.Log; import android.webkit.MimeTypeMap; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.List; /* * A simple ContentProvider that allows file access to Email's attachments. * * The URI scheme is as follows. For raw file access: * content://com.android.email.attachmentprovider/acct#/attach#/RAW * * And for access to thumbnails: * content://com.android.email.attachmentprovider/acct#/attach#/THUMBNAIL/width#/height# * * The on-disk (storage) schema is as follows. * * Attachments are stored at: /account#.db_att/item# * Thumbnails are stored at: /thmb_account#_item# * * Using the standard application context, account #10 and attachment # 20, this would be: * /data/data/com.android.email/databases/10.db_att/20 * /data/data/com.android.email/cache/thmb_10_20 */ public class AttachmentProvider extends ContentProvider { public static final String AUTHORITY = "com.android.email.attachmentprovider"; public static final Uri CONTENT_URI = Uri.parse( "content://" + AUTHORITY); private static final String FORMAT_RAW = "RAW"; private static final String FORMAT_THUMBNAIL = "THUMBNAIL"; public static class AttachmentProviderColumns { public static final String _ID = "_id"; public static final String DATA = "_data"; public static final String DISPLAY_NAME = "_display_name"; public static final String SIZE = "_size"; } private static final String[] MIME_TYPE_PROJECTION = new String[] { AttachmentColumns.MIME_TYPE, AttachmentColumns.FILENAME }; private static final int MIME_TYPE_COLUMN_MIME_TYPE = 0; private static final int MIME_TYPE_COLUMN_FILENAME = 1; private static final String[] PROJECTION_QUERY = new String[] { AttachmentColumns.FILENAME, AttachmentColumns.SIZE, AttachmentColumns.CONTENT_URI }; public static Uri getAttachmentUri(long accountId, long id) { return CONTENT_URI.buildUpon() .appendPath(Long.toString(accountId)) .appendPath(Long.toString(id)) .appendPath(FORMAT_RAW) .build(); } public static Uri getAttachmentThumbnailUri(long accountId, long id, int width, int height) { return CONTENT_URI.buildUpon() .appendPath(Long.toString(accountId)) .appendPath(Long.toString(id)) .appendPath(FORMAT_THUMBNAIL) .appendPath(Integer.toString(width)) .appendPath(Integer.toString(height)) .build(); } /** * Return the filename for a given attachment. This should be used by any code that is * going to *write* attachments. * * This does not create or write the file, or even the directories. It simply builds * the filename that should be used. */ public static File getAttachmentFilename(Context context, long accountId, long attachmentId) { return new File(getAttachmentDirectory(context, accountId), Long.toString(attachmentId)); } /** * Return the directory for a given attachment. This should be used by any code that is * going to *write* attachments. * * This does not create or write the directory. It simply builds the pathname that should be * used. */ public static File getAttachmentDirectory(Context context, long accountId) { return context.getDatabasePath(accountId + ".db_att"); } @Override public boolean onCreate() { /* * We use the cache dir as a temporary directory (since Android doesn't give us one) so * on startup we'll clean up any .tmp files from the last run. */ File[] files = getContext().getCacheDir().listFiles(); for (File file : files) { String filename = file.getName(); if (filename.endsWith(".tmp") || filename.startsWith("thmb_")) { file.delete(); } } return true; } /** * Returns the mime type for a given attachment. There are three possible results: * - If thumbnail Uri, always returns "image/png" (even if there's no attachment) * - If the attachment does not exist, returns null * - Returns the mime type of the attachment */ @Override public String getType(Uri uri) { long callingId = Binder.clearCallingIdentity(); try { List segments = uri.getPathSegments(); String id = segments.get(1); String format = segments.get(2); if (FORMAT_THUMBNAIL.equals(format)) { return "image/png"; } else { uri = ContentUris.withAppendedId(Attachment.CONTENT_URI, Long.parseLong(id)); Cursor c = getContext().getContentResolver().query(uri, MIME_TYPE_PROJECTION, null, null, null); try { if (c.moveToFirst()) { String mimeType = c.getString(MIME_TYPE_COLUMN_MIME_TYPE); String fileName = c.getString(MIME_TYPE_COLUMN_FILENAME); mimeType = inferMimeType(fileName, mimeType); return mimeType; } } finally { c.close(); } return null; } } finally { Binder.restoreCallingIdentity(callingId); } } /** * Helper to convert unknown or unmapped attachments to something useful based on filename * extensions. The mime type is inferred based upon the table below. It's not perfect, but * it helps. * *
     *                   |---------------------------------------------------------|
     *                   |                  E X T E N S I O N                      |
     *                   |---------------------------------------------------------|
     *                   | .eml        | known(.png) | unknown(.abc) | none        |
     * | M |-----------------------------------------------------------------------|
     * | I | none        | msg/rfc822  | image/png   | app/abc       | app/oct-str |
     * | M |-------------| (always     |             |               |             |
     * | E | app/oct-str |  overrides  |             |               |             |
     * | T |-------------|             |             |-----------------------------|
     * | Y | text/plain  |             |             | text/plain                  |
     * | P |-------------|             |-------------------------------------------|
     * | E | any/type    |             | any/type                                  |
     * |---|-----------------------------------------------------------------------|
     * 
* * NOTE: Since mime types on Android are case-*sensitive*, return values are always in * lower case. * * @param fileName The given filename * @param mimeType The given mime type * @return A likely mime type for the attachment */ public static String inferMimeType(final String fileName, final String mimeType) { String resultType = null; String fileExtension = getFilenameExtension(fileName); boolean isTextPlain = "text/plain".equalsIgnoreCase(mimeType); if ("eml".equals(fileExtension)) { resultType = "message/rfc822"; } else { boolean isGenericType = isTextPlain || "application/octet-stream".equalsIgnoreCase(mimeType); // If the given mime type is non-empty and non-generic, return it if (isGenericType || TextUtils.isEmpty(mimeType)) { if (!TextUtils.isEmpty(fileExtension)) { // Otherwise, try to find a mime type based upon the file extension resultType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension); if (TextUtils.isEmpty(resultType)) { // Finally, if original mimetype is text/plain, use it; otherwise synthesize resultType = isTextPlain ? mimeType : "application/" + fileExtension; } } } else { resultType = mimeType; } } // No good guess could be made; use an appropriate generic type if (TextUtils.isEmpty(resultType)) { resultType = isTextPlain ? "text/plain" : "application/octet-stream"; } return resultType.toLowerCase(); } /** * Extract and return filename's extension, converted to lower case, and not including the "." * * @return extension, or null if not found (or null/empty filename) */ public static String getFilenameExtension(String fileName) { String extension = null; if (!TextUtils.isEmpty(fileName)) { int lastDot = fileName.lastIndexOf('.'); if ((lastDot > 0) && (lastDot < fileName.length() - 1)) { extension = fileName.substring(lastDot + 1).toLowerCase(); } } return extension; } /** * Open an attachment file. There are two "modes" - "raw", which returns an actual file, * and "thumbnail", which attempts to generate a thumbnail image. * * Thumbnails are cached for easy space recovery and cleanup. * * TODO: The thumbnail mode returns null for its failure cases, instead of throwing * FileNotFoundException, and should be fixed for consistency. * * @throws FileNotFoundException */ @Override public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { long callingId = Binder.clearCallingIdentity(); try { List segments = uri.getPathSegments(); String accountId = segments.get(0); String id = segments.get(1); String format = segments.get(2); if (FORMAT_THUMBNAIL.equals(format)) { int width = Integer.parseInt(segments.get(3)); int height = Integer.parseInt(segments.get(4)); String filename = "thmb_" + accountId + "_" + id; File dir = getContext().getCacheDir(); File file = new File(dir, filename); if (!file.exists()) { Uri attachmentUri = getAttachmentUri(Long.parseLong(accountId), Long.parseLong(id)); Cursor c = query(attachmentUri, new String[] { AttachmentProviderColumns.DATA }, null, null, null); if (c != null) { try { if (c.moveToFirst()) { attachmentUri = Uri.parse(c.getString(0)); } else { return null; } } finally { c.close(); } } String type = getContext().getContentResolver().getType(attachmentUri); try { InputStream in = getContext().getContentResolver().openInputStream(attachmentUri); Bitmap thumbnail = createThumbnail(type, in); if (thumbnail == null) { return null; } thumbnail = Bitmap.createScaledBitmap(thumbnail, width, height, true); FileOutputStream out = new FileOutputStream(file); thumbnail.compress(Bitmap.CompressFormat.PNG, 100, out); out.close(); in.close(); } catch (IOException ioe) { Log.d(Email.LOG_TAG, "openFile/thumbnail failed with " + ioe.getMessage()); return null; } catch (OutOfMemoryError oome) { Log.d(Email.LOG_TAG, "openFile/thumbnail failed with " + oome.getMessage()); return null; } } return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); } else { return ParcelFileDescriptor.open( new File(getContext().getDatabasePath(accountId + ".db_att"), id), ParcelFileDescriptor.MODE_READ_ONLY); } } finally { Binder.restoreCallingIdentity(callingId); } } @Override public int delete(Uri uri, String arg1, String[] arg2) { return 0; } @Override public Uri insert(Uri uri, ContentValues values) { return null; } /** * Returns a cursor based on the data in the attachments table, or null if the attachment * is not recorded in the table. * * Supports REST Uri only, for a single row - selection, selection args, and sortOrder are * ignored (non-null values should probably throw an exception....) */ @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { long callingId = Binder.clearCallingIdentity(); try { if (projection == null) { projection = new String[] { AttachmentProviderColumns._ID, AttachmentProviderColumns.DATA, }; } List segments = uri.getPathSegments(); String accountId = segments.get(0); String id = segments.get(1); String format = segments.get(2); String name = null; int size = -1; String contentUri = null; uri = ContentUris.withAppendedId(Attachment.CONTENT_URI, Long.parseLong(id)); Cursor c = getContext().getContentResolver().query(uri, PROJECTION_QUERY, null, null, null); try { if (c.moveToFirst()) { name = c.getString(0); size = c.getInt(1); contentUri = c.getString(2); } else { return null; } } finally { c.close(); } MatrixCursor ret = new MatrixCursor(projection); Object[] values = new Object[projection.length]; for (int i = 0, count = projection.length; i < count; i++) { String column = projection[i]; if (AttachmentProviderColumns._ID.equals(column)) { values[i] = id; } else if (AttachmentProviderColumns.DATA.equals(column)) { values[i] = contentUri; } else if (AttachmentProviderColumns.DISPLAY_NAME.equals(column)) { values[i] = name; } else if (AttachmentProviderColumns.SIZE.equals(column)) { values[i] = size; } } ret.addRow(values); return ret; } finally { Binder.restoreCallingIdentity(callingId); } } @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { return 0; } private Bitmap createThumbnail(String type, InputStream data) { if(MimeUtility.mimeTypeMatches(type, "image/*")) { return createImageThumbnail(data); } return null; } private Bitmap createImageThumbnail(InputStream data) { try { Bitmap bitmap = BitmapFactory.decodeStream(data); return bitmap; } catch (OutOfMemoryError oome) { Log.d(Email.LOG_TAG, "createImageThumbnail failed with " + oome.getMessage()); return null; } catch (Exception e) { Log.d(Email.LOG_TAG, "createImageThumbnail failed with " + e.getMessage()); return null; } } /** * Resolve attachment id to content URI. Returns the resolved content URI (from the attachment * DB) or, if not found, simply returns the incoming value. * * @param attachmentUri * @return resolved content URI * * TODO: Throws an SQLite exception on a missing DB file (e.g. unknown URI) instead of just * returning the incoming uri, as it should. */ public static Uri resolveAttachmentIdToContentUri(ContentResolver resolver, Uri attachmentUri) { Cursor c = resolver.query(attachmentUri, new String[] { AttachmentProvider.AttachmentProviderColumns.DATA }, null, null, null); if (c != null) { try { if (c.moveToFirst()) { final String strUri = c.getString(0); if (strUri != null) { return Uri.parse(strUri); } else { Email.log("AttachmentProvider: attachment with null contentUri"); } } } finally { c.close(); } } return attachmentUri; } /** * In support of deleting a message, find all attachments and delete associated attachment * files. * @param context * @param accountId the account for the message * @param messageId the message */ public static void deleteAllAttachmentFiles(Context context, long accountId, long messageId) { Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId); Cursor c = context.getContentResolver().query(uri, Attachment.ID_PROJECTION, null, null, null); try { while (c.moveToNext()) { long attachmentId = c.getLong(Attachment.ID_PROJECTION_COLUMN); File attachmentFile = getAttachmentFilename(context, accountId, attachmentId); // Note, delete() throws no exceptions for basic FS errors (e.g. file not found) // it just returns false, which we ignore, and proceed to the next file. // This entire loop is best-effort only. attachmentFile.delete(); } } finally { c.close(); } } /** * In support of deleting a mailbox, find all messages and delete their attachments. * * @param context * @param accountId the account for the mailbox * @param mailboxId the mailbox for the messages */ public static void deleteAllMailboxAttachmentFiles(Context context, long accountId, long mailboxId) { Cursor c = context.getContentResolver().query(Message.CONTENT_URI, Message.ID_COLUMN_PROJECTION, MessageColumns.MAILBOX_KEY + "=?", new String[] { Long.toString(mailboxId) }, null); try { while (c.moveToNext()) { long messageId = c.getLong(Message.ID_PROJECTION_COLUMN); deleteAllAttachmentFiles(context, accountId, messageId); } } finally { c.close(); } } /** * In support of deleting or wiping an account, delete all related attachments. * * @param context * @param accountId the account to scrub */ public static void deleteAllAccountAttachmentFiles(Context context, long accountId) { File[] files = getAttachmentDirectory(context, accountId).listFiles(); if (files == null) return; for (File file : files) { boolean result = file.delete(); if (!result) { Log.e(Email.LOG_TAG, "Failed to delete attachment file " + file.getName()); } } } /** * Need this to suppress warning in unit tests. */ @Override public void shutdown() { // Don't call super.shutdown(), which emits a warning... } }