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