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