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 17 package com.android.email.mail.store; 18 19 import com.android.email.Email; 20 import com.android.email.Utility; 21 import com.android.email.mail.Address; 22 import com.android.email.mail.Body; 23 import com.android.email.mail.FetchProfile; 24 import com.android.email.mail.Flag; 25 import com.android.email.mail.Folder; 26 import com.android.email.mail.Message; 27 import com.android.email.mail.Message.RecipientType; 28 import com.android.email.mail.MessagingException; 29 import com.android.email.mail.Part; 30 import com.android.email.mail.Store; 31 import com.android.email.mail.Store.PersistentDataCallbacks; 32 import com.android.email.mail.internet.MimeBodyPart; 33 import com.android.email.mail.internet.MimeHeader; 34 import com.android.email.mail.internet.MimeMessage; 35 import com.android.email.mail.internet.MimeMultipart; 36 import com.android.email.mail.internet.MimeUtility; 37 import com.android.email.mail.internet.TextBody; 38 39 import org.apache.commons.io.IOUtils; 40 41 import android.content.ContentValues; 42 import android.content.Context; 43 import android.database.Cursor; 44 import android.database.sqlite.SQLiteDatabase; 45 import android.net.Uri; 46 import android.util.Log; 47 import android.util.Base64; 48 import android.util.Base64OutputStream; 49 50 import java.io.ByteArrayInputStream; 51 import java.io.File; 52 import java.io.FileNotFoundException; 53 import java.io.FileOutputStream; 54 import java.io.IOException; 55 import java.io.InputStream; 56 import java.io.OutputStream; 57 import java.io.UnsupportedEncodingException; 58 import java.net.URI; 59 import java.net.URLEncoder; 60 import java.util.ArrayList; 61 import java.util.Date; 62 import java.util.UUID; 63 64 /** 65 * <pre> 66 * Implements a SQLite database backed local store for Messages. 67 * </pre> 68 */ 69 public class LocalStore extends Store implements PersistentDataCallbacks { 70 /** 71 * History of database revisions. 72 * 73 * db version Shipped in Notes 74 * ---------- ---------- ----- 75 * 18 pre-1.0 Development versions. No upgrade path. 76 * 18 1.0, 1.1 1.0 Release version. 77 * 19 - Added message_id column to messages table. 78 * 20 1.5 Added content_id column to attachments table. 79 * 21 - Added remote_store_data table 80 * 22 - Added store_flag_1 and store_flag_2 columns to messages table. 81 * 23 - Added flag_downloaded_full, flag_downloaded_partial, flag_deleted 82 * columns to message table. 83 * 24 - Added x_headers to messages table. 84 */ 85 86 private static final int DB_VERSION = 24; 87 88 private static final Flag[] PERMANENT_FLAGS = { Flag.DELETED, Flag.X_DESTROYED, Flag.SEEN }; 89 90 private final String mPath; 91 private SQLiteDatabase mDb; 92 private final File mAttachmentsDir; 93 private final Context mContext; 94 private int mVisibleLimitDefault = -1; 95 96 /** 97 * Static named constructor. 98 */ 99 public static LocalStore newInstance(String uri, Context context, 100 PersistentDataCallbacks callbacks) throws MessagingException { 101 return new LocalStore(uri, context); 102 } 103 104 /** 105 * @param uri local://localhost/path/to/database/uuid.db 106 */ 107 private LocalStore(String _uri, Context context) throws MessagingException { 108 mContext = context; 109 URI uri = null; 110 try { 111 uri = new URI(_uri); 112 } catch (Exception e) { 113 throw new MessagingException("Invalid uri for LocalStore"); 114 } 115 if (!uri.getScheme().equals("local")) { 116 throw new MessagingException("Invalid scheme"); 117 } 118 mPath = uri.getPath(); 119 120 File parentDir = new File(mPath).getParentFile(); 121 if (!parentDir.exists()) { 122 parentDir.mkdirs(); 123 } 124 mDb = SQLiteDatabase.openOrCreateDatabase(mPath, null); 125 int oldVersion = mDb.getVersion(); 126 127 /* 128 * TODO we should have more sophisticated way to upgrade database. 129 */ 130 if (oldVersion != DB_VERSION) { 131 if (Email.LOGD) { 132 Log.v(Email.LOG_TAG, String.format("Upgrading database from %d to %d", 133 oldVersion, DB_VERSION)); 134 } 135 if (oldVersion < 18) { 136 /** 137 * Missing or old: Create up-to-date tables 138 */ 139 mDb.execSQL("DROP TABLE IF EXISTS folders"); 140 mDb.execSQL("CREATE TABLE folders (id INTEGER PRIMARY KEY, name TEXT, " 141 + "last_updated INTEGER, unread_count INTEGER, visible_limit INTEGER)"); 142 143 mDb.execSQL("DROP TABLE IF EXISTS messages"); 144 mDb.execSQL("CREATE TABLE messages (id INTEGER PRIMARY KEY, folder_id INTEGER, " + 145 "uid TEXT, subject TEXT, date INTEGER, flags TEXT, sender_list TEXT, " + 146 "to_list TEXT, cc_list TEXT, bcc_list TEXT, reply_to_list TEXT, " + 147 "html_content TEXT, text_content TEXT, attachment_count INTEGER, " + 148 "internal_date INTEGER, message_id TEXT, store_flag_1 INTEGER, " + 149 "store_flag_2 INTEGER, flag_downloaded_full INTEGER," + 150 "flag_downloaded_partial INTEGER, flag_deleted INTEGER, x_headers TEXT)"); 151 152 mDb.execSQL("DROP TABLE IF EXISTS attachments"); 153 mDb.execSQL("CREATE TABLE attachments (id INTEGER PRIMARY KEY, message_id INTEGER," 154 + "store_data TEXT, content_uri TEXT, size INTEGER, name TEXT," 155 + "mime_type TEXT, content_id TEXT)"); 156 157 mDb.execSQL("DROP TABLE IF EXISTS pending_commands"); 158 mDb.execSQL("CREATE TABLE pending_commands " + 159 "(id INTEGER PRIMARY KEY, command TEXT, arguments TEXT)"); 160 161 addRemoteStoreDataTable(); 162 163 addFolderDeleteTrigger(); 164 165 mDb.execSQL("DROP TRIGGER IF EXISTS delete_message"); 166 mDb.execSQL("CREATE TRIGGER delete_message BEFORE DELETE ON messages BEGIN DELETE FROM attachments WHERE old.id = message_id; END;"); 167 mDb.setVersion(DB_VERSION); 168 } 169 else { 170 if (oldVersion < 19) { 171 /** 172 * Upgrade 18 to 19: add message_id to messages table 173 */ 174 mDb.execSQL("ALTER TABLE messages ADD COLUMN message_id TEXT;"); 175 mDb.setVersion(19); 176 } 177 if (oldVersion < 20) { 178 /** 179 * Upgrade 19 to 20: add content_id to attachments table 180 */ 181 mDb.execSQL("ALTER TABLE attachments ADD COLUMN content_id TEXT;"); 182 mDb.setVersion(20); 183 } 184 if (oldVersion < 21) { 185 /** 186 * Upgrade 20 to 21: add remote_store_data and update triggers to match 187 */ 188 addRemoteStoreDataTable(); 189 addFolderDeleteTrigger(); 190 mDb.setVersion(21); 191 } 192 if (oldVersion < 22) { 193 /** 194 * Upgrade 21 to 22: add store_flag_1 and store_flag_2 to messages table 195 */ 196 mDb.execSQL("ALTER TABLE messages ADD COLUMN store_flag_1 INTEGER;"); 197 mDb.execSQL("ALTER TABLE messages ADD COLUMN store_flag_2 INTEGER;"); 198 mDb.setVersion(22); 199 } 200 if (oldVersion < 23) { 201 /** 202 * Upgrade 22 to 23: add flag_downloaded_full & flag_downloaded_partial 203 * and flag_deleted columns to message table *and upgrade existing messages*. 204 */ 205 mDb.beginTransaction(); 206 try { 207 mDb.execSQL( 208 "ALTER TABLE messages ADD COLUMN flag_downloaded_full INTEGER;"); 209 mDb.execSQL( 210 "ALTER TABLE messages ADD COLUMN flag_downloaded_partial INTEGER;"); 211 mDb.execSQL( 212 "ALTER TABLE messages ADD COLUMN flag_deleted INTEGER;"); 213 migrateMessageFlags(); 214 mDb.setVersion(23); 215 mDb.setTransactionSuccessful(); 216 } finally { 217 mDb.endTransaction(); 218 } 219 } 220 if (oldVersion < 24) { 221 /** 222 * Upgrade 23 to 24: add x_headers to messages table 223 */ 224 mDb.execSQL("ALTER TABLE messages ADD COLUMN x_headers TEXT;"); 225 mDb.setVersion(24); 226 } 227 } 228 229 if (mDb.getVersion() != DB_VERSION) { 230 throw new Error("Database upgrade failed!"); 231 } 232 } 233 mAttachmentsDir = new File(mPath + "_att"); 234 if (!mAttachmentsDir.exists()) { 235 mAttachmentsDir.mkdirs(); 236 } 237 } 238 239 /** 240 * Common code to add the remote_store_data table 241 */ 242 private void addRemoteStoreDataTable() { 243 mDb.execSQL("DROP TABLE IF EXISTS remote_store_data"); 244 mDb.execSQL("CREATE TABLE remote_store_data (" + 245 "id INTEGER PRIMARY KEY, folder_id INTEGER, data_key TEXT, data TEXT, " + 246 "UNIQUE (folder_id, data_key) ON CONFLICT REPLACE" + 247 ")"); 248 } 249 250 /** 251 * Common code to add folder delete trigger 252 */ 253 private void addFolderDeleteTrigger() { 254 mDb.execSQL("DROP TRIGGER IF EXISTS delete_folder"); 255 mDb.execSQL("CREATE TRIGGER delete_folder " 256 + "BEFORE DELETE ON folders " 257 + "BEGIN " 258 + "DELETE FROM messages WHERE old.id = folder_id; " 259 + "DELETE FROM remote_store_data WHERE old.id = folder_id; " 260 + "END;"); 261 } 262 263 /** 264 * When upgrading from 22 to 23, we have to move any flags "X_DOWNLOADED_FULL" or 265 * "X_DOWNLOADED_PARTIAL" or "DELETED" from the old string-based storage to their own columns. 266 * 267 * Note: Caller should open a db transaction around this 268 */ 269 private void migrateMessageFlags() { 270 Cursor cursor = mDb.query("messages", 271 new String[] { "id", "flags" }, 272 null, null, null, null, null); 273 try { 274 int columnId = cursor.getColumnIndexOrThrow("id"); 275 int columnFlags = cursor.getColumnIndexOrThrow("flags"); 276 277 while (cursor.moveToNext()) { 278 String oldFlags = cursor.getString(columnFlags); 279 ContentValues values = new ContentValues(); 280 int newFlagDlFull = 0; 281 int newFlagDlPartial = 0; 282 int newFlagDeleted = 0; 283 if (oldFlags != null) { 284 if (oldFlags.contains(Flag.X_DOWNLOADED_FULL.toString())) { 285 newFlagDlFull = 1; 286 } 287 if (oldFlags.contains(Flag.X_DOWNLOADED_PARTIAL.toString())) { 288 newFlagDlPartial = 1; 289 } 290 if (oldFlags.contains(Flag.DELETED.toString())) { 291 newFlagDeleted = 1; 292 } 293 } 294 // Always commit the new flags. 295 // Note: We don't have to pay the cost of rewriting the old string, 296 // because the old flag will be ignored, and will eventually be overwritten 297 // anyway. 298 values.put("flag_downloaded_full", newFlagDlFull); 299 values.put("flag_downloaded_partial", newFlagDlPartial); 300 values.put("flag_deleted", newFlagDeleted); 301 int rowId = cursor.getInt(columnId); 302 mDb.update("messages", values, "id=" + rowId, null); 303 } 304 } finally { 305 cursor.close(); 306 } 307 } 308 309 @Override 310 public Folder getFolder(String name) throws MessagingException { 311 return new LocalFolder(name); 312 } 313 314 // TODO this takes about 260-300ms, seems slow. 315 @Override 316 public Folder[] getPersonalNamespaces() throws MessagingException { 317 ArrayList<Folder> folders = new ArrayList<Folder>(); 318 Cursor cursor = null; 319 try { 320 cursor = mDb.rawQuery("SELECT name FROM folders", null); 321 while (cursor.moveToNext()) { 322 folders.add(new LocalFolder(cursor.getString(0))); 323 } 324 } 325 finally { 326 if (cursor != null) { 327 cursor.close(); 328 } 329 } 330 return folders.toArray(new Folder[] {}); 331 } 332 333 @Override 334 public void checkSettings() throws MessagingException { 335 } 336 337 /** 338 * Local store only: Allow it to be closed. This is necessary for the account upgrade process 339 * because we open and close each database a few times as we proceed. 340 */ 341 public void close() { 342 try { 343 mDb.close(); 344 mDb = null; 345 } catch (Exception e) { 346 // Log and discard. This is best-effort, and database finalizers will try again. 347 Log.d(Email.LOG_TAG, "Caught exception while closing localstore db: " + e); 348 } 349 } 350 351 /** 352 * Delete the entire Store and it's backing database. 353 */ 354 @Override 355 public void delete() { 356 try { 357 mDb.close(); 358 } catch (Exception e) { 359 360 } 361 try{ 362 File[] attachments = mAttachmentsDir.listFiles(); 363 for (File attachment : attachments) { 364 if (attachment.exists()) { 365 attachment.delete(); 366 } 367 } 368 if (mAttachmentsDir.exists()) { 369 mAttachmentsDir.delete(); 370 } 371 } 372 catch (Exception e) { 373 } 374 try { 375 new File(mPath).delete(); 376 } 377 catch (Exception e) { 378 379 } 380 } 381 382 /** 383 * Report # of attachments (for migration estimates only - catches all exceptions and 384 * just returns zero) 385 */ 386 public int getStoredAttachmentCount() { 387 try{ 388 File[] attachments = mAttachmentsDir.listFiles(); 389 return attachments.length; 390 } 391 catch (Exception e) { 392 return 0; 393 } 394 } 395 396 /** 397 * Deletes all cached attachments for the entire store. 398 */ 399 public int pruneCachedAttachments() throws MessagingException { 400 int prunedCount = 0; 401 File[] files = mAttachmentsDir.listFiles(); 402 for (File file : files) { 403 if (file.exists()) { 404 try { 405 Cursor cursor = null; 406 try { 407 cursor = mDb.query( 408 "attachments", 409 new String[] { "store_data" }, 410 "id = ?", 411 new String[] { file.getName() }, 412 null, 413 null, 414 null); 415 if (cursor.moveToNext()) { 416 if (cursor.getString(0) == null) { 417 /* 418 * If the attachment has no store data it is not recoverable, so 419 * we won't delete it. 420 */ 421 continue; 422 } 423 } 424 } 425 finally { 426 if (cursor != null) { 427 cursor.close(); 428 } 429 } 430 ContentValues cv = new ContentValues(); 431 cv.putNull("content_uri"); 432 mDb.update("attachments", cv, "id = ?", new String[] { file.getName() }); 433 } 434 catch (Exception e) { 435 /* 436 * If the row has gone away before we got to mark it not-downloaded that's 437 * okay. 438 */ 439 } 440 if (!file.delete()) { 441 file.deleteOnExit(); 442 } 443 prunedCount++; 444 } 445 } 446 return prunedCount; 447 } 448 449 /** 450 * Set the visible limit for all folders in a given store. 451 * 452 * @param visibleLimit the value to write to all folders. -1 may also be used as a marker. 453 */ 454 public void resetVisibleLimits(int visibleLimit) { 455 mVisibleLimitDefault = visibleLimit; // used for future Folder.create ops 456 ContentValues cv = new ContentValues(); 457 cv.put("visible_limit", Integer.toString(visibleLimit)); 458 mDb.update("folders", cv, null, null); 459 } 460 461 public ArrayList<PendingCommand> getPendingCommands() { 462 Cursor cursor = null; 463 try { 464 cursor = mDb.query("pending_commands", 465 new String[] { "id", "command", "arguments" }, 466 null, 467 null, 468 null, 469 null, 470 "id ASC"); 471 ArrayList<PendingCommand> commands = new ArrayList<PendingCommand>(); 472 while (cursor.moveToNext()) { 473 PendingCommand command = new PendingCommand(); 474 command.mId = cursor.getLong(0); 475 command.command = cursor.getString(1); 476 String arguments = cursor.getString(2); 477 command.arguments = arguments.split(","); 478 for (int i = 0; i < command.arguments.length; i++) { 479 command.arguments[i] = Utility.fastUrlDecode(command.arguments[i]); 480 } 481 commands.add(command); 482 } 483 return commands; 484 } 485 finally { 486 if (cursor != null) { 487 cursor.close(); 488 } 489 } 490 } 491 492 public void addPendingCommand(PendingCommand command) { 493 try { 494 for (int i = 0; i < command.arguments.length; i++) { 495 command.arguments[i] = URLEncoder.encode(command.arguments[i], "UTF-8"); 496 } 497 ContentValues cv = new ContentValues(); 498 cv.put("command", command.command); 499 cv.put("arguments", Utility.combine(command.arguments, ',')); 500 mDb.insert("pending_commands", "command", cv); 501 } 502 catch (UnsupportedEncodingException usee) { 503 throw new Error("Aparently UTF-8 has been lost to the annals of history."); 504 } 505 } 506 507 public void removePendingCommand(PendingCommand command) { 508 mDb.delete("pending_commands", "id = ?", new String[] { Long.toString(command.mId) }); 509 } 510 511 public static class PendingCommand { 512 private long mId; 513 public String command; 514 public String[] arguments; 515 516 @Override 517 public String toString() { 518 StringBuffer sb = new StringBuffer(); 519 sb.append(command); 520 sb.append("\n"); 521 for (String argument : arguments) { 522 sb.append(" "); 523 sb.append(argument); 524 sb.append("\n"); 525 } 526 return sb.toString(); 527 } 528 } 529 530 /** 531 * LocalStore-only function to get the callbacks API 532 */ 533 public PersistentDataCallbacks getPersistentCallbacks() throws MessagingException { 534 return this; 535 } 536 537 public String getPersistentString(String key, String defaultValue) { 538 return getPersistentString(-1, key, defaultValue); 539 } 540 541 public void setPersistentString(String key, String value) { 542 setPersistentString(-1, key, value); 543 } 544 545 /** 546 * Common implementation of getPersistentString 547 * @param folderId The id of the associated folder, or -1 for "store" values 548 * @param key The key 549 * @param defaultValue The value to return if the row is not found 550 * @return The row data or the default 551 */ 552 private String getPersistentString(long folderId, String key, String defaultValue) { 553 String result = defaultValue; 554 Cursor cursor = null; 555 try { 556 cursor = mDb.query("remote_store_data", 557 new String[] { "data" }, 558 "folder_id = ? AND data_key = ?", 559 new String[] { Long.toString(folderId), key }, 560 null, 561 null, 562 null); 563 if (cursor != null && cursor.moveToNext()) { 564 result = cursor.getString(0); 565 } 566 } 567 finally { 568 if (cursor != null) { 569 cursor.close(); 570 } 571 } 572 return result; 573 } 574 575 /** 576 * Common implementation of setPersistentString 577 * @param folderId The id of the associated folder, or -1 for "store" values 578 * @param key The key 579 * @param value The value to store 580 */ 581 private void setPersistentString(long folderId, String key, String value) { 582 ContentValues cv = new ContentValues(); 583 cv.put("folder_id", Long.toString(folderId)); 584 cv.put("data_key", key); 585 cv.put("data", value); 586 // Note: Table has on-conflict-replace rule 587 mDb.insert("remote_store_data", null, cv); 588 } 589 590 public class LocalFolder extends Folder implements Folder.PersistentDataCallbacks { 591 private final String mName; 592 private long mFolderId = -1; 593 private int mUnreadMessageCount = -1; 594 private int mVisibleLimit = -1; 595 596 public LocalFolder(String name) { 597 this.mName = name; 598 } 599 600 public long getId() { 601 return mFolderId; 602 } 603 604 /** 605 * This is just used by the internal callers 606 */ 607 private void open(OpenMode mode) throws MessagingException { 608 open(mode, null); 609 } 610 611 @Override 612 public void open(OpenMode mode, PersistentDataCallbacks callbacks) 613 throws MessagingException { 614 if (isOpen()) { 615 return; 616 } 617 if (!exists()) { 618 create(FolderType.HOLDS_MESSAGES); 619 } 620 Cursor cursor = null; 621 try { 622 cursor = mDb.rawQuery("SELECT id, unread_count, visible_limit FROM folders " 623 + "where folders.name = ?", 624 new String[] { 625 mName 626 }); 627 if (!cursor.moveToFirst()) { 628 throw new MessagingException("Nonexistent folder"); 629 } 630 mFolderId = cursor.getInt(0); 631 mUnreadMessageCount = cursor.getInt(1); 632 mVisibleLimit = cursor.getInt(2); 633 } 634 finally { 635 if (cursor != null) { 636 cursor.close(); 637 } 638 } 639 } 640 641 @Override 642 public boolean isOpen() { 643 return mFolderId != -1; 644 } 645 646 @Override 647 public OpenMode getMode() throws MessagingException { 648 return OpenMode.READ_WRITE; 649 } 650 651 @Override 652 public String getName() { 653 return mName; 654 } 655 656 @Override 657 public boolean exists() throws MessagingException { 658 return Utility.arrayContains(getPersonalNamespaces(), this); 659 } 660 661 // LocalStore supports folder creation 662 @Override 663 public boolean canCreate(FolderType type) { 664 return true; 665 } 666 667 @Override 668 public boolean create(FolderType type) throws MessagingException { 669 if (exists()) { 670 throw new MessagingException("Folder " + mName + " already exists."); 671 } 672 mDb.execSQL("INSERT INTO folders (name, visible_limit) VALUES (?, ?)", new Object[] { 673 mName, 674 mVisibleLimitDefault 675 }); 676 return true; 677 } 678 679 @Override 680 public void close(boolean expunge) throws MessagingException { 681 if (expunge) { 682 expunge(); 683 } 684 mFolderId = -1; 685 } 686 687 @Override 688 public int getMessageCount() throws MessagingException { 689 return getMessageCount(null, null); 690 } 691 692 /** 693 * Return number of messages based on the state of the flags. 694 * 695 * @param setFlags The flags that should be set for a message to be selected (null ok) 696 * @param clearFlags The flags that should be clear for a message to be selected (null ok) 697 * @return The number of messages matching the desired flag states. 698 * @throws MessagingException 699 */ 700 public int getMessageCount(Flag[] setFlags, Flag[] clearFlags) throws MessagingException { 701 // Generate WHERE clause based on flags observed 702 StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM messages WHERE "); 703 buildFlagPredicates(sql, setFlags, clearFlags); 704 sql.append("messages.folder_id = ?"); 705 706 open(OpenMode.READ_WRITE); 707 Cursor cursor = null; 708 try { 709 cursor = mDb.rawQuery( 710 sql.toString(), 711 new String[] { 712 Long.toString(mFolderId) 713 }); 714 cursor.moveToFirst(); 715 int messageCount = cursor.getInt(0); 716 return messageCount; 717 } 718 finally { 719 if (cursor != null) { 720 cursor.close(); 721 } 722 } 723 } 724 725 @Override 726 public int getUnreadMessageCount() throws MessagingException { 727 if (!isOpen()) { 728 // opening it will read all columns including mUnreadMessageCount 729 open(OpenMode.READ_WRITE); 730 } else { 731 // already open. refresh from db in case another instance wrote to it 732 Cursor cursor = null; 733 try { 734 cursor = mDb.rawQuery("SELECT unread_count FROM folders WHERE folders.name = ?", 735 new String[] { mName }); 736 if (!cursor.moveToFirst()) { 737 throw new MessagingException("Nonexistent folder"); 738 } 739 mUnreadMessageCount = cursor.getInt(0); 740 } finally { 741 if (cursor != null) { 742 cursor.close(); 743 } 744 } 745 } 746 return mUnreadMessageCount; 747 } 748 749 public void setUnreadMessageCount(int unreadMessageCount) throws MessagingException { 750 open(OpenMode.READ_WRITE); 751 mUnreadMessageCount = Math.max(0, unreadMessageCount); 752 mDb.execSQL("UPDATE folders SET unread_count = ? WHERE id = ?", 753 new Object[] { mUnreadMessageCount, mFolderId }); 754 } 755 756 public int getVisibleLimit() throws MessagingException { 757 if (!isOpen()) { 758 // opening it will read all columns including mVisibleLimit 759 open(OpenMode.READ_WRITE); 760 } else { 761 // already open. refresh from db in case another instance wrote to it 762 Cursor cursor = null; 763 try { 764 cursor = mDb.rawQuery( 765 "SELECT visible_limit FROM folders WHERE folders.name = ?", 766 new String[] { mName }); 767 if (!cursor.moveToFirst()) { 768 throw new MessagingException("Nonexistent folder"); 769 } 770 mVisibleLimit = cursor.getInt(0); 771 } finally { 772 if (cursor != null) { 773 cursor.close(); 774 } 775 } 776 } 777 return mVisibleLimit; 778 } 779 780 public void setVisibleLimit(int visibleLimit) throws MessagingException { 781 open(OpenMode.READ_WRITE); 782 mVisibleLimit = visibleLimit; 783 mDb.execSQL("UPDATE folders SET visible_limit = ? WHERE id = ?", 784 new Object[] { mVisibleLimit, mFolderId }); 785 } 786 787 /** 788 * Supports FetchProfile.Item.BODY and FetchProfile.Item.STRUCTURE 789 */ 790 @Override 791 public void fetch(Message[] messages, FetchProfile fp, MessageRetrievalListener listener) 792 throws MessagingException { 793 open(OpenMode.READ_WRITE); 794 if (fp.contains(FetchProfile.Item.BODY) || fp.contains(FetchProfile.Item.STRUCTURE)) { 795 for (Message message : messages) { 796 LocalMessage localMessage = (LocalMessage)message; 797 Cursor cursor = null; 798 localMessage.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "multipart/mixed"); 799 MimeMultipart mp = new MimeMultipart(); 800 mp.setSubType("mixed"); 801 localMessage.setBody(mp); 802 803 // If fetching the body, retrieve html & plaintext from DB. 804 // If fetching structure, simply build placeholders for them. 805 if (fp.contains(FetchProfile.Item.BODY)) { 806 try { 807 cursor = mDb.rawQuery("SELECT html_content, text_content FROM messages " 808 + "WHERE id = ?", 809 new String[] { Long.toString(localMessage.mId) }); 810 cursor.moveToNext(); 811 String htmlContent = cursor.getString(0); 812 String textContent = cursor.getString(1); 813 814 if (htmlContent != null) { 815 TextBody body = new TextBody(htmlContent); 816 MimeBodyPart bp = new MimeBodyPart(body, "text/html"); 817 mp.addBodyPart(bp); 818 } 819 820 if (textContent != null) { 821 TextBody body = new TextBody(textContent); 822 MimeBodyPart bp = new MimeBodyPart(body, "text/plain"); 823 mp.addBodyPart(bp); 824 } 825 } 826 finally { 827 if (cursor != null) { 828 cursor.close(); 829 } 830 } 831 } else { 832 MimeBodyPart bp = new MimeBodyPart(); 833 bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, 834 "text/html;\n charset=\"UTF-8\""); 835 mp.addBodyPart(bp); 836 837 bp = new MimeBodyPart(); 838 bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, 839 "text/plain;\n charset=\"UTF-8\""); 840 mp.addBodyPart(bp); 841 } 842 843 try { 844 cursor = mDb.query( 845 "attachments", 846 new String[] { 847 "id", 848 "size", 849 "name", 850 "mime_type", 851 "store_data", 852 "content_uri", 853 "content_id" }, 854 "message_id = ?", 855 new String[] { Long.toString(localMessage.mId) }, 856 null, 857 null, 858 null); 859 860 while (cursor.moveToNext()) { 861 long id = cursor.getLong(0); 862 int size = cursor.getInt(1); 863 String name = cursor.getString(2); 864 String type = cursor.getString(3); 865 String storeData = cursor.getString(4); 866 String contentUri = cursor.getString(5); 867 String contentId = cursor.getString(6); 868 Body body = null; 869 if (contentUri != null) { 870 body = new LocalAttachmentBody(Uri.parse(contentUri), mContext); 871 } 872 MimeBodyPart bp = new LocalAttachmentBodyPart(body, id); 873 bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, 874 String.format("%s;\n name=\"%s\"", 875 type, 876 name)); 877 bp.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64"); 878 bp.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, 879 String.format("attachment;\n filename=\"%s\";\n size=%d", 880 name, 881 size)); 882 bp.setHeader(MimeHeader.HEADER_CONTENT_ID, contentId); 883 884 /* 885 * HEADER_ANDROID_ATTACHMENT_STORE_DATA is a custom header we add to that 886 * we can later pull the attachment from the remote store if neccesary. 887 */ 888 bp.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, storeData); 889 890 mp.addBodyPart(bp); 891 } 892 } 893 finally { 894 if (cursor != null) { 895 cursor.close(); 896 } 897 } 898 } 899 } 900 } 901 902 /** 903 * The columns to select when calling populateMessageFromGetMessageCursor() 904 */ 905 private final String POPULATE_MESSAGE_SELECT_COLUMNS = 906 "subject, sender_list, date, uid, flags, id, to_list, cc_list, " + 907 "bcc_list, reply_to_list, attachment_count, internal_date, message_id, " + 908 "store_flag_1, store_flag_2, flag_downloaded_full, flag_downloaded_partial, " + 909 "flag_deleted, x_headers"; 910 911 /** 912 * Populate a message from a cursor with the following columns: 913 * 914 * 0 subject 915 * 1 from address 916 * 2 date (long) 917 * 3 uid 918 * 4 flag list (older flags - comma-separated string) 919 * 5 local id (long) 920 * 6 to addresses 921 * 7 cc addresses 922 * 8 bcc addresses 923 * 9 reply-to address 924 * 10 attachment count (int) 925 * 11 internal date (long) 926 * 12 message id (from Mime headers) 927 * 13 store flag 1 928 * 14 store flag 2 929 * 15 flag "downloaded full" 930 * 16 flag "downloaded partial" 931 * 17 flag "deleted" 932 * 18 extended headers ("\r\n"-separated string) 933 */ 934 private void populateMessageFromGetMessageCursor(LocalMessage message, Cursor cursor) 935 throws MessagingException{ 936 message.setSubject(cursor.getString(0) == null ? "" : cursor.getString(0)); 937 Address[] from = Address.legacyUnpack(cursor.getString(1)); 938 if (from.length > 0) { 939 message.setFrom(from[0]); 940 } 941 message.setSentDate(new Date(cursor.getLong(2))); 942 message.setUid(cursor.getString(3)); 943 String flagList = cursor.getString(4); 944 if (flagList != null && flagList.length() > 0) { 945 String[] flags = flagList.split(","); 946 try { 947 for (String flag : flags) { 948 message.setFlagInternal(Flag.valueOf(flag.toUpperCase()), true); 949 } 950 } catch (Exception e) { 951 } 952 } 953 message.mId = cursor.getLong(5); 954 message.setRecipients(RecipientType.TO, Address.legacyUnpack(cursor.getString(6))); 955 message.setRecipients(RecipientType.CC, Address.legacyUnpack(cursor.getString(7))); 956 message.setRecipients(RecipientType.BCC, Address.legacyUnpack(cursor.getString(8))); 957 message.setReplyTo(Address.legacyUnpack(cursor.getString(9))); 958 message.mAttachmentCount = cursor.getInt(10); 959 message.setInternalDate(new Date(cursor.getLong(11))); 960 message.setMessageId(cursor.getString(12)); 961 message.setFlagInternal(Flag.X_STORE_1, (0 != cursor.getInt(13))); 962 message.setFlagInternal(Flag.X_STORE_2, (0 != cursor.getInt(14))); 963 message.setFlagInternal(Flag.X_DOWNLOADED_FULL, (0 != cursor.getInt(15))); 964 message.setFlagInternal(Flag.X_DOWNLOADED_PARTIAL, (0 != cursor.getInt(16))); 965 message.setFlagInternal(Flag.DELETED, (0 != cursor.getInt(17))); 966 message.setExtendedHeaders(cursor.getString(18)); 967 } 968 969 @Override 970 public Message[] getMessages(int start, int end, MessageRetrievalListener listener) 971 throws MessagingException { 972 open(OpenMode.READ_WRITE); 973 throw new MessagingException( 974 "LocalStore.getMessages(int, int, MessageRetrievalListener) not yet implemented"); 975 } 976 977 @Override 978 public Message getMessage(String uid) throws MessagingException { 979 open(OpenMode.READ_WRITE); 980 LocalMessage message = new LocalMessage(uid, this); 981 Cursor cursor = null; 982 try { 983 cursor = mDb.rawQuery( 984 "SELECT " + POPULATE_MESSAGE_SELECT_COLUMNS + 985 " FROM messages" + 986 " WHERE uid = ? AND folder_id = ?", 987 new String[] { 988 message.getUid(), Long.toString(mFolderId) 989 }); 990 if (!cursor.moveToNext()) { 991 return null; 992 } 993 populateMessageFromGetMessageCursor(message, cursor); 994 } 995 finally { 996 if (cursor != null) { 997 cursor.close(); 998 } 999 } 1000 return message; 1001 } 1002 1003 @Override 1004 public Message[] getMessages(MessageRetrievalListener listener) throws MessagingException { 1005 open(OpenMode.READ_WRITE); 1006 ArrayList<Message> messages = new ArrayList<Message>(); 1007 Cursor cursor = null; 1008 try { 1009 cursor = mDb.rawQuery( 1010 "SELECT " + POPULATE_MESSAGE_SELECT_COLUMNS + 1011 " FROM messages" + 1012 " WHERE folder_id = ?", 1013 new String[] { 1014 Long.toString(mFolderId) 1015 }); 1016 1017 while (cursor.moveToNext()) { 1018 LocalMessage message = new LocalMessage(null, this); 1019 populateMessageFromGetMessageCursor(message, cursor); 1020 messages.add(message); 1021 } 1022 } 1023 finally { 1024 if (cursor != null) { 1025 cursor.close(); 1026 } 1027 } 1028 1029 return messages.toArray(new Message[] {}); 1030 } 1031 1032 @Override 1033 public Message[] getMessages(String[] uids, MessageRetrievalListener listener) 1034 throws MessagingException { 1035 open(OpenMode.READ_WRITE); 1036 if (uids == null) { 1037 return getMessages(listener); 1038 } 1039 ArrayList<Message> messages = new ArrayList<Message>(); 1040 for (String uid : uids) { 1041 messages.add(getMessage(uid)); 1042 } 1043 return messages.toArray(new Message[] {}); 1044 } 1045 1046 /** 1047 * Return a set of messages based on the state of the flags. 1048 * 1049 * @param setFlags The flags that should be set for a message to be selected (null ok) 1050 * @param clearFlags The flags that should be clear for a message to be selected (null ok) 1051 * @param listener 1052 * @return A list of messages matching the desired flag states. 1053 * @throws MessagingException 1054 */ 1055 @Override 1056 public Message[] getMessages(Flag[] setFlags, Flag[] clearFlags, 1057 MessageRetrievalListener listener) throws MessagingException { 1058 // Generate WHERE clause based on flags observed 1059 StringBuilder sql = new StringBuilder( 1060 "SELECT " + POPULATE_MESSAGE_SELECT_COLUMNS + 1061 " FROM messages" + 1062 " WHERE "); 1063 buildFlagPredicates(sql, setFlags, clearFlags); 1064 sql.append("folder_id = ?"); 1065 1066 open(OpenMode.READ_WRITE); 1067 ArrayList<Message> messages = new ArrayList<Message>(); 1068 1069 Cursor cursor = null; 1070 try { 1071 cursor = mDb.rawQuery( 1072 sql.toString(), 1073 new String[] { 1074 Long.toString(mFolderId) 1075 }); 1076 1077 while (cursor.moveToNext()) { 1078 LocalMessage message = new LocalMessage(null, this); 1079 populateMessageFromGetMessageCursor(message, cursor); 1080 messages.add(message); 1081 } 1082 } finally { 1083 if (cursor != null) { 1084 cursor.close(); 1085 } 1086 } 1087 1088 return messages.toArray(new Message[] {}); 1089 } 1090 1091 /* 1092 * Build SQL where predicates expression from set and clear flag arrays. 1093 */ 1094 private void buildFlagPredicates(StringBuilder sql, Flag[] setFlags, Flag[] clearFlags) 1095 throws MessagingException { 1096 if (setFlags != null) { 1097 for (Flag flag : setFlags) { 1098 if (flag == Flag.X_STORE_1) { 1099 sql.append("store_flag_1 = 1 AND "); 1100 } else if (flag == Flag.X_STORE_2) { 1101 sql.append("store_flag_2 = 1 AND "); 1102 } else if (flag == Flag.X_DOWNLOADED_FULL) { 1103 sql.append("flag_downloaded_full = 1 AND "); 1104 } else if (flag == Flag.X_DOWNLOADED_PARTIAL) { 1105 sql.append("flag_downloaded_partial = 1 AND "); 1106 } else if (flag == Flag.DELETED) { 1107 sql.append("flag_deleted = 1 AND "); 1108 } else { 1109 throw new MessagingException("Unsupported flag " + flag); 1110 } 1111 } 1112 } 1113 if (clearFlags != null) { 1114 for (Flag flag : clearFlags) { 1115 if (flag == Flag.X_STORE_1) { 1116 sql.append("store_flag_1 = 0 AND "); 1117 } else if (flag == Flag.X_STORE_2) { 1118 sql.append("store_flag_2 = 0 AND "); 1119 } else if (flag == Flag.X_DOWNLOADED_FULL) { 1120 sql.append("flag_downloaded_full = 0 AND "); 1121 } else if (flag == Flag.X_DOWNLOADED_PARTIAL) { 1122 sql.append("flag_downloaded_partial = 0 AND "); 1123 } else if (flag == Flag.DELETED) { 1124 sql.append("flag_deleted = 0 AND "); 1125 } else { 1126 throw new MessagingException("Unsupported flag " + flag); 1127 } 1128 } 1129 } 1130 } 1131 1132 @Override 1133 public void copyMessages(Message[] msgs, Folder folder, MessageUpdateCallbacks callbacks) 1134 throws MessagingException { 1135 if (!(folder instanceof LocalFolder)) { 1136 throw new MessagingException("copyMessages called with incorrect Folder"); 1137 } 1138 ((LocalFolder) folder).appendMessages(msgs, true); 1139 } 1140 1141 /** 1142 * The method differs slightly from the contract; If an incoming message already has a uid 1143 * assigned and it matches the uid of an existing message then this message will replace the 1144 * old message. It is implemented as a delete/insert. This functionality is used in saving 1145 * of drafts and re-synchronization of updated server messages. 1146 */ 1147 @Override 1148 public void appendMessages(Message[] messages) throws MessagingException { 1149 appendMessages(messages, false); 1150 } 1151 1152 /** 1153 * The method differs slightly from the contract; If an incoming message already has a uid 1154 * assigned and it matches the uid of an existing message then this message will replace the 1155 * old message. It is implemented as a delete/insert. This functionality is used in saving 1156 * of drafts and re-synchronization of updated server messages. 1157 */ 1158 public void appendMessages(Message[] messages, boolean copy) throws MessagingException { 1159 open(OpenMode.READ_WRITE); 1160 for (Message message : messages) { 1161 if (!(message instanceof MimeMessage)) { 1162 throw new Error("LocalStore can only store Messages that extend MimeMessage"); 1163 } 1164 1165 String uid = message.getUid(); 1166 if (uid == null) { 1167 message.setUid("Local" + UUID.randomUUID().toString()); 1168 } 1169 else { 1170 /* 1171 * The message may already exist in this Folder, so delete it first. 1172 */ 1173 deleteAttachments(message.getUid()); 1174 mDb.execSQL("DELETE FROM messages WHERE folder_id = ? AND uid = ?", 1175 new Object[] { mFolderId, message.getUid() }); 1176 } 1177 1178 ArrayList<Part> viewables = new ArrayList<Part>(); 1179 ArrayList<Part> attachments = new ArrayList<Part>(); 1180 MimeUtility.collectParts(message, viewables, attachments); 1181 1182 StringBuffer sbHtml = new StringBuffer(); 1183 StringBuffer sbText = new StringBuffer(); 1184 for (Part viewable : viewables) { 1185 try { 1186 String text = MimeUtility.getTextFromPart(viewable); 1187 /* 1188 * Anything with MIME type text/html will be stored as such. Anything 1189 * else will be stored as text/plain. 1190 */ 1191 if (viewable.getMimeType().equalsIgnoreCase("text/html")) { 1192 sbHtml.append(text); 1193 } 1194 else { 1195 sbText.append(text); 1196 } 1197 } catch (Exception e) { 1198 throw new MessagingException("Unable to get text for message part", e); 1199 } 1200 } 1201 1202 try { 1203 ContentValues cv = new ContentValues(); 1204 cv.put("uid", message.getUid()); 1205 cv.put("subject", message.getSubject()); 1206 cv.put("sender_list", Address.legacyPack(message.getFrom())); 1207 cv.put("date", message.getSentDate() == null 1208 ? System.currentTimeMillis() : message.getSentDate().getTime()); 1209 cv.put("flags", makeFlagsString(message)); 1210 cv.put("folder_id", mFolderId); 1211 cv.put("to_list", Address.legacyPack(message.getRecipients(RecipientType.TO))); 1212 cv.put("cc_list", Address.legacyPack(message.getRecipients(RecipientType.CC))); 1213 cv.put("bcc_list", Address.legacyPack( 1214 message.getRecipients(RecipientType.BCC))); 1215 cv.put("html_content", sbHtml.length() > 0 ? sbHtml.toString() : null); 1216 cv.put("text_content", sbText.length() > 0 ? sbText.toString() : null); 1217 cv.put("reply_to_list", Address.legacyPack(message.getReplyTo())); 1218 cv.put("attachment_count", attachments.size()); 1219 cv.put("internal_date", message.getInternalDate() == null 1220 ? System.currentTimeMillis() : message.getInternalDate().getTime()); 1221 cv.put("message_id", ((MimeMessage)message).getMessageId()); 1222 cv.put("store_flag_1", makeFlagNumeric(message, Flag.X_STORE_1)); 1223 cv.put("store_flag_2", makeFlagNumeric(message, Flag.X_STORE_2)); 1224 cv.put("flag_downloaded_full", 1225 makeFlagNumeric(message, Flag.X_DOWNLOADED_FULL)); 1226 cv.put("flag_downloaded_partial", 1227 makeFlagNumeric(message, Flag.X_DOWNLOADED_PARTIAL)); 1228 cv.put("flag_deleted", makeFlagNumeric(message, Flag.DELETED)); 1229 cv.put("x_headers", ((MimeMessage) message).getExtendedHeaders()); 1230 long messageId = mDb.insert("messages", "uid", cv); 1231 for (Part attachment : attachments) { 1232 saveAttachment(messageId, attachment, copy); 1233 } 1234 } catch (Exception e) { 1235 throw new MessagingException("Error appending message", e); 1236 } 1237 } 1238 } 1239 1240 /** 1241 * Update the given message in the LocalStore without first deleting the existing 1242 * message (contrast with appendMessages). This method is used to store changes 1243 * to the given message while updating attachments and not removing existing 1244 * attachment data. 1245 * TODO In the future this method should be combined with appendMessages since the Message 1246 * contains enough data to decide what to do. 1247 * @param message 1248 * @throws MessagingException 1249 */ 1250 public void updateMessage(LocalMessage message) throws MessagingException { 1251 open(OpenMode.READ_WRITE); 1252 ArrayList<Part> viewables = new ArrayList<Part>(); 1253 ArrayList<Part> attachments = new ArrayList<Part>(); 1254 MimeUtility.collectParts(message, viewables, attachments); 1255 1256 StringBuffer sbHtml = new StringBuffer(); 1257 StringBuffer sbText = new StringBuffer(); 1258 for (int i = 0, count = viewables.size(); i < count; i++) { 1259 Part viewable = viewables.get(i); 1260 try { 1261 String text = MimeUtility.getTextFromPart(viewable); 1262 /* 1263 * Anything with MIME type text/html will be stored as such. Anything 1264 * else will be stored as text/plain. 1265 */ 1266 if (viewable.getMimeType().equalsIgnoreCase("text/html")) { 1267 sbHtml.append(text); 1268 } 1269 else { 1270 sbText.append(text); 1271 } 1272 } catch (Exception e) { 1273 throw new MessagingException("Unable to get text for message part", e); 1274 } 1275 } 1276 1277 try { 1278 mDb.execSQL("UPDATE messages SET " 1279 + "uid = ?, subject = ?, sender_list = ?, date = ?, flags = ?, " 1280 + "folder_id = ?, to_list = ?, cc_list = ?, bcc_list = ?, " 1281 + "html_content = ?, text_content = ?, reply_to_list = ?, " 1282 + "attachment_count = ?, message_id = ?, store_flag_1 = ?, " 1283 + "store_flag_2 = ?, flag_downloaded_full = ?, " 1284 + "flag_downloaded_partial = ?, flag_deleted = ?, x_headers = ? " 1285 + "WHERE id = ?", 1286 new Object[] { 1287 message.getUid(), 1288 message.getSubject(), 1289 Address.legacyPack(message.getFrom()), 1290 message.getSentDate() == null ? System 1291 .currentTimeMillis() : message.getSentDate() 1292 .getTime(), 1293 makeFlagsString(message), 1294 mFolderId, 1295 Address.legacyPack(message 1296 .getRecipients(RecipientType.TO)), 1297 Address.legacyPack(message 1298 .getRecipients(RecipientType.CC)), 1299 Address.legacyPack(message 1300 .getRecipients(RecipientType.BCC)), 1301 sbHtml.length() > 0 ? sbHtml.toString() : null, 1302 sbText.length() > 0 ? sbText.toString() : null, 1303 Address.legacyPack(message.getReplyTo()), 1304 attachments.size(), 1305 message.getMessageId(), 1306 makeFlagNumeric(message, Flag.X_STORE_1), 1307 makeFlagNumeric(message, Flag.X_STORE_2), 1308 makeFlagNumeric(message, Flag.X_DOWNLOADED_FULL), 1309 makeFlagNumeric(message, Flag.X_DOWNLOADED_PARTIAL), 1310 makeFlagNumeric(message, Flag.DELETED), 1311 message.getExtendedHeaders(), 1312 1313 message.mId 1314 }); 1315 1316 for (int i = 0, count = attachments.size(); i < count; i++) { 1317 Part attachment = attachments.get(i); 1318 saveAttachment(message.mId, attachment, false); 1319 } 1320 } catch (Exception e) { 1321 throw new MessagingException("Error appending message", e); 1322 } 1323 } 1324 1325 /** 1326 * @param messageId 1327 * @param attachment 1328 * @param attachmentId -1 to create a new attachment or >= 0 to update an existing 1329 * @throws IOException 1330 * @throws MessagingException 1331 */ 1332 private void saveAttachment(long messageId, Part attachment, boolean saveAsNew) 1333 throws IOException, MessagingException { 1334 long attachmentId = -1; 1335 Uri contentUri = null; 1336 int size = -1; 1337 File tempAttachmentFile = null; 1338 1339 if ((!saveAsNew) && (attachment instanceof LocalAttachmentBodyPart)) { 1340 attachmentId = ((LocalAttachmentBodyPart) attachment).getAttachmentId(); 1341 } 1342 1343 if (attachment.getBody() != null) { 1344 Body body = attachment.getBody(); 1345 if (body instanceof LocalAttachmentBody) { 1346 contentUri = ((LocalAttachmentBody) body).getContentUri(); 1347 } 1348 else { 1349 /* 1350 * If the attachment has a body we're expected to save it into the local store 1351 * so we copy the data into a cached attachment file. 1352 */ 1353 InputStream in = attachment.getBody().getInputStream(); 1354 tempAttachmentFile = File.createTempFile("att", null, mAttachmentsDir); 1355 FileOutputStream out = new FileOutputStream(tempAttachmentFile); 1356 size = IOUtils.copy(in, out); 1357 in.close(); 1358 out.close(); 1359 } 1360 } 1361 1362 if (size == -1) { 1363 /* 1364 * If the attachment is not yet downloaded see if we can pull a size 1365 * off the Content-Disposition. 1366 */ 1367 String disposition = attachment.getDisposition(); 1368 if (disposition != null) { 1369 String s = MimeUtility.getHeaderParameter(disposition, "size"); 1370 if (s != null) { 1371 size = Integer.parseInt(s); 1372 } 1373 } 1374 } 1375 if (size == -1) { 1376 size = 0; 1377 } 1378 1379 String storeData = 1380 Utility.combine(attachment.getHeader( 1381 MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA), ','); 1382 1383 String name = MimeUtility.getHeaderParameter(attachment.getContentType(), "name"); 1384 String contentId = attachment.getContentId(); 1385 1386 if (attachmentId == -1) { 1387 ContentValues cv = new ContentValues(); 1388 cv.put("message_id", messageId); 1389 cv.put("content_uri", contentUri != null ? contentUri.toString() : null); 1390 cv.put("store_data", storeData); 1391 cv.put("size", size); 1392 cv.put("name", name); 1393 cv.put("mime_type", attachment.getMimeType()); 1394 cv.put("content_id", contentId); 1395 1396 attachmentId = mDb.insert("attachments", "message_id", cv); 1397 } 1398 else { 1399 ContentValues cv = new ContentValues(); 1400 cv.put("content_uri", contentUri != null ? contentUri.toString() : null); 1401 cv.put("size", size); 1402 cv.put("content_id", contentId); 1403 cv.put("message_id", messageId); 1404 mDb.update( 1405 "attachments", 1406 cv, 1407 "id = ?", 1408 new String[] { Long.toString(attachmentId) }); 1409 } 1410 1411 if (tempAttachmentFile != null) { 1412 File attachmentFile = new File(mAttachmentsDir, Long.toString(attachmentId)); 1413 tempAttachmentFile.renameTo(attachmentFile); 1414 // Doing this requires knowing the account id 1415 // contentUri = AttachmentProvider.getAttachmentUri( 1416 // new File(mPath).getName(), 1417 // attachmentId); 1418 attachment.setBody(new LocalAttachmentBody(contentUri, mContext)); 1419 ContentValues cv = new ContentValues(); 1420 cv.put("content_uri", contentUri != null ? contentUri.toString() : null); 1421 mDb.update( 1422 "attachments", 1423 cv, 1424 "id = ?", 1425 new String[] { Long.toString(attachmentId) }); 1426 } 1427 1428 if (attachment instanceof LocalAttachmentBodyPart) { 1429 ((LocalAttachmentBodyPart) attachment).setAttachmentId(attachmentId); 1430 } 1431 } 1432 1433 /** 1434 * Changes the stored uid of the given message (using it's internal id as a key) to 1435 * the uid in the message. 1436 * @param message 1437 */ 1438 public void changeUid(LocalMessage message) throws MessagingException { 1439 open(OpenMode.READ_WRITE); 1440 ContentValues cv = new ContentValues(); 1441 cv.put("uid", message.getUid()); 1442 mDb.update("messages", cv, "id = ?", new String[] { Long.toString(message.mId) }); 1443 } 1444 1445 @Override 1446 public void setFlags(Message[] messages, Flag[] flags, boolean value) 1447 throws MessagingException { 1448 open(OpenMode.READ_WRITE); 1449 for (Message message : messages) { 1450 message.setFlags(flags, value); 1451 } 1452 } 1453 1454 @Override 1455 public Message[] expunge() throws MessagingException { 1456 open(OpenMode.READ_WRITE); 1457 ArrayList<Message> expungedMessages = new ArrayList<Message>(); 1458 /* 1459 * epunge() doesn't do anything because deleted messages are saved for their uids 1460 * and really, really deleted messages are "Destroyed" and removed immediately. 1461 */ 1462 return expungedMessages.toArray(new Message[] {}); 1463 } 1464 1465 @Override 1466 public void delete(boolean recurse) throws MessagingException { 1467 // We need to open the folder first to make sure we've got it's id 1468 open(OpenMode.READ_ONLY); 1469 Message[] messages = getMessages(null); 1470 for (Message message : messages) { 1471 deleteAttachments(message.getUid()); 1472 } 1473 mDb.execSQL("DELETE FROM folders WHERE id = ?", new Object[] { 1474 Long.toString(mFolderId), 1475 }); 1476 } 1477 1478 @Override 1479 public boolean equals(Object o) { 1480 if (o instanceof LocalFolder) { 1481 return ((LocalFolder)o).mName.equals(mName); 1482 } 1483 return super.equals(o); 1484 } 1485 1486 @Override 1487 public Flag[] getPermanentFlags() throws MessagingException { 1488 return PERMANENT_FLAGS; 1489 } 1490 1491 private void deleteAttachments(String uid) throws MessagingException { 1492 open(OpenMode.READ_WRITE); 1493 Cursor messagesCursor = null; 1494 try { 1495 messagesCursor = mDb.query( 1496 "messages", 1497 new String[] { "id" }, 1498 "folder_id = ? AND uid = ?", 1499 new String[] { Long.toString(mFolderId), uid }, 1500 null, 1501 null, 1502 null); 1503 while (messagesCursor.moveToNext()) { 1504 long messageId = messagesCursor.getLong(0); 1505 Cursor attachmentsCursor = null; 1506 try { 1507 attachmentsCursor = mDb.query( 1508 "attachments", 1509 new String[] { "id" }, 1510 "message_id = ?", 1511 new String[] { Long.toString(messageId) }, 1512 null, 1513 null, 1514 null); 1515 while (attachmentsCursor.moveToNext()) { 1516 long attachmentId = attachmentsCursor.getLong(0); 1517 try{ 1518 File file = new File(mAttachmentsDir, Long.toString(attachmentId)); 1519 if (file.exists()) { 1520 file.delete(); 1521 } 1522 } 1523 catch (Exception e) { 1524 1525 } 1526 } 1527 } 1528 finally { 1529 if (attachmentsCursor != null) { 1530 attachmentsCursor.close(); 1531 } 1532 } 1533 } 1534 } 1535 finally { 1536 if (messagesCursor != null) { 1537 messagesCursor.close(); 1538 } 1539 } 1540 } 1541 1542 /** 1543 * Support for local persistence for our remote stores. 1544 * Will open the folder if necessary. 1545 */ 1546 public Folder.PersistentDataCallbacks getPersistentCallbacks() throws MessagingException { 1547 open(OpenMode.READ_WRITE); 1548 return this; 1549 } 1550 1551 public String getPersistentString(String key, String defaultValue) { 1552 return LocalStore.this.getPersistentString(mFolderId, key, defaultValue); 1553 } 1554 1555 public void setPersistentString(String key, String value) { 1556 LocalStore.this.setPersistentString(mFolderId, key, value); 1557 } 1558 1559 /** 1560 * Transactionally combine a key/value and a complete message flags flip. Used 1561 * for setting sync bits in messages. 1562 * 1563 * Note: Not all flags are supported here and can only be changed with Message.setFlag(). 1564 * For example, Flag.DELETED has side effects (removes attachments). 1565 * 1566 * @param key 1567 * @param value 1568 * @param setFlags 1569 * @param clearFlags 1570 */ 1571 public void setPersistentStringAndMessageFlags(String key, String value, 1572 Flag[] setFlags, Flag[] clearFlags) throws MessagingException { 1573 mDb.beginTransaction(); 1574 try { 1575 // take care of folder persistence 1576 if (key != null) { 1577 setPersistentString(key, value); 1578 } 1579 1580 // take care of flags 1581 ContentValues cv = new ContentValues(); 1582 if (setFlags != null) { 1583 for (Flag flag : setFlags) { 1584 if (flag == Flag.X_STORE_1) { 1585 cv.put("store_flag_1", 1); 1586 } else if (flag == Flag.X_STORE_2) { 1587 cv.put("store_flag_2", 1); 1588 } else if (flag == Flag.X_DOWNLOADED_FULL) { 1589 cv.put("flag_downloaded_full", 1); 1590 } else if (flag == Flag.X_DOWNLOADED_PARTIAL) { 1591 cv.put("flag_downloaded_partial", 1); 1592 } else { 1593 throw new MessagingException("Unsupported flag " + flag); 1594 } 1595 } 1596 } 1597 if (clearFlags != null) { 1598 for (Flag flag : clearFlags) { 1599 if (flag == Flag.X_STORE_1) { 1600 cv.put("store_flag_1", 0); 1601 } else if (flag == Flag.X_STORE_2) { 1602 cv.put("store_flag_2", 0); 1603 } else if (flag == Flag.X_DOWNLOADED_FULL) { 1604 cv.put("flag_downloaded_full", 0); 1605 } else if (flag == Flag.X_DOWNLOADED_PARTIAL) { 1606 cv.put("flag_downloaded_partial", 0); 1607 } else { 1608 throw new MessagingException("Unsupported flag " + flag); 1609 } 1610 } 1611 } 1612 mDb.update("messages", cv, 1613 "folder_id = ?", new String[] { Long.toString(mFolderId) }); 1614 1615 mDb.setTransactionSuccessful(); 1616 } finally { 1617 mDb.endTransaction(); 1618 } 1619 1620 } 1621 1622 @Override 1623 public Message createMessage(String uid) throws MessagingException { 1624 return new LocalMessage(uid, this); 1625 } 1626 } 1627 1628 public class LocalMessage extends MimeMessage { 1629 private long mId; 1630 private int mAttachmentCount; 1631 1632 LocalMessage(String uid, Folder folder) throws MessagingException { 1633 this.mUid = uid; 1634 this.mFolder = folder; 1635 } 1636 1637 public int getAttachmentCount() { 1638 return mAttachmentCount; 1639 } 1640 1641 @Override 1642 public void parse(InputStream in) throws IOException, MessagingException { 1643 super.parse(in); 1644 } 1645 1646 public void setFlagInternal(Flag flag, boolean set) throws MessagingException { 1647 super.setFlag(flag, set); 1648 } 1649 1650 public long getId() { 1651 return mId; 1652 } 1653 1654 @Override 1655 public void setFlag(Flag flag, boolean set) throws MessagingException { 1656 if (flag == Flag.DELETED && set) { 1657 /* 1658 * If a message is being marked as deleted we want to clear out it's content 1659 * and attachments as well. Delete will not actually remove the row since we need 1660 * to retain the uid for synchronization purposes. 1661 */ 1662 1663 /* 1664 * Delete all of the messages' content to save space. 1665 */ 1666 mDb.execSQL( 1667 "UPDATE messages SET " + 1668 "subject = NULL, " + 1669 "sender_list = NULL, " + 1670 "date = NULL, " + 1671 "to_list = NULL, " + 1672 "cc_list = NULL, " + 1673 "bcc_list = NULL, " + 1674 "html_content = NULL, " + 1675 "text_content = NULL, " + 1676 "reply_to_list = NULL " + 1677 "WHERE id = ?", 1678 new Object[] { 1679 mId 1680 }); 1681 1682 ((LocalFolder) mFolder).deleteAttachments(getUid()); 1683 1684 /* 1685 * Delete all of the messages' attachments to save space. 1686 */ 1687 mDb.execSQL("DELETE FROM attachments WHERE id = ?", 1688 new Object[] { 1689 mId 1690 }); 1691 } 1692 else if (flag == Flag.X_DESTROYED && set) { 1693 ((LocalFolder) mFolder).deleteAttachments(getUid()); 1694 mDb.execSQL("DELETE FROM messages WHERE id = ?", 1695 new Object[] { mId }); 1696 } 1697 1698 /* 1699 * Update the unread count on the folder. 1700 */ 1701 try { 1702 if (flag == Flag.DELETED || flag == Flag.X_DESTROYED || flag == Flag.SEEN) { 1703 LocalFolder folder = (LocalFolder)mFolder; 1704 if (set && !isSet(Flag.SEEN)) { 1705 folder.setUnreadMessageCount(folder.getUnreadMessageCount() - 1); 1706 } 1707 else if (!set && isSet(Flag.SEEN)) { 1708 folder.setUnreadMessageCount(folder.getUnreadMessageCount() + 1); 1709 } 1710 } 1711 } 1712 catch (MessagingException me) { 1713 Log.e(Email.LOG_TAG, "Unable to update LocalStore unread message count", 1714 me); 1715 throw new RuntimeException(me); 1716 } 1717 1718 super.setFlag(flag, set); 1719 /* 1720 * Set the flags on the message. 1721 */ 1722 mDb.execSQL("UPDATE messages " 1723 + "SET flags = ?, store_flag_1 = ?, store_flag_2 = ?, " 1724 + "flag_downloaded_full = ?, flag_downloaded_partial = ?, flag_deleted = ? " 1725 + "WHERE id = ?", 1726 new Object[] { 1727 makeFlagsString(this), 1728 makeFlagNumeric(this, Flag.X_STORE_1), 1729 makeFlagNumeric(this, Flag.X_STORE_2), 1730 makeFlagNumeric(this, Flag.X_DOWNLOADED_FULL), 1731 makeFlagNumeric(this, Flag.X_DOWNLOADED_PARTIAL), 1732 makeFlagNumeric(this, Flag.DELETED), 1733 mId 1734 }); 1735 } 1736 } 1737 1738 /** 1739 * Convert *old* flags to flags string. Some flags are kept in their own columns 1740 * (for selecting) and are not included here. 1741 * @param message The message containing the flag(s) 1742 * @return a comma-separated list of flags, to write into the "flags" column 1743 */ 1744 /* package */ String makeFlagsString(Message message) { 1745 StringBuilder sb = null; 1746 boolean nonEmpty = false; 1747 for (Flag flag : Flag.values()) { 1748 if (flag != Flag.X_STORE_1 && flag != Flag.X_STORE_2 && 1749 flag != Flag.X_DOWNLOADED_FULL && flag != Flag.X_DOWNLOADED_PARTIAL && 1750 flag != Flag.DELETED && 1751 message.isSet(flag)) { 1752 if (sb == null) { 1753 sb = new StringBuilder(); 1754 } 1755 if (nonEmpty) { 1756 sb.append(','); 1757 } 1758 sb.append(flag.toString()); 1759 nonEmpty = true; 1760 } 1761 } 1762 return (sb == null) ? null : sb.toString(); 1763 } 1764 1765 /** 1766 * Convert flags to numeric form (0 or 1) for database storage. 1767 * @param message The message containing the flag of interest 1768 * @param flag The flag of interest 1769 * 1770 */ 1771 /* package */ int makeFlagNumeric(Message message, Flag flag) { 1772 return message.isSet(flag) ? 1 : 0; 1773 } 1774 1775 1776 public class LocalAttachmentBodyPart extends MimeBodyPart { 1777 private long mAttachmentId = -1; 1778 1779 public LocalAttachmentBodyPart(Body body, long attachmentId) throws MessagingException { 1780 super(body); 1781 mAttachmentId = attachmentId; 1782 } 1783 1784 /** 1785 * Returns the local attachment id of this body, or -1 if it is not stored. 1786 * @return 1787 */ 1788 public long getAttachmentId() { 1789 return mAttachmentId; 1790 } 1791 1792 public void setAttachmentId(long attachmentId) { 1793 mAttachmentId = attachmentId; 1794 } 1795 1796 @Override 1797 public String toString() { 1798 return "" + mAttachmentId; 1799 } 1800 } 1801 1802 public static class LocalAttachmentBody implements Body { 1803 private Context mContext; 1804 private Uri mUri; 1805 1806 public LocalAttachmentBody(Uri uri, Context context) { 1807 mContext = context; 1808 mUri = uri; 1809 } 1810 1811 public InputStream getInputStream() throws MessagingException { 1812 try { 1813 return mContext.getContentResolver().openInputStream(mUri); 1814 } 1815 catch (FileNotFoundException fnfe) { 1816 /* 1817 * Since it's completely normal for us to try to serve up attachments that 1818 * have been blown away, we just return an empty stream. 1819 */ 1820 return new ByteArrayInputStream(new byte[0]); 1821 } 1822 catch (IOException ioe) { 1823 throw new MessagingException("Invalid attachment.", ioe); 1824 } 1825 } 1826 1827 public void writeTo(OutputStream out) throws IOException, MessagingException { 1828 InputStream in = getInputStream(); 1829 Base64OutputStream base64Out = new Base64OutputStream( 1830 out, Base64.CRLF | Base64.NO_CLOSE); 1831 IOUtils.copy(in, base64Out); 1832 base64Out.close(); 1833 } 1834 1835 public Uri getContentUri() { 1836 return mUri; 1837 } 1838 } 1839 1840 /** 1841 * LocalStore does not have SettingActivity. 1842 */ 1843 @Override 1844 public Class<? extends android.app.Activity> getSettingActivityClass() { 1845 return null; 1846 } 1847 } 1848