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