/* * Copyright (C) 2008 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.email.mail.store; import com.android.email.Email; import com.android.email.Utility; import com.android.email.mail.Address; import com.android.email.mail.Body; import com.android.email.mail.FetchProfile; import com.android.email.mail.Flag; import com.android.email.mail.Folder; import com.android.email.mail.Message; import com.android.email.mail.Message.RecipientType; import com.android.email.mail.MessageRetrievalListener; import com.android.email.mail.MessagingException; import com.android.email.mail.Part; import com.android.email.mail.Store; import com.android.email.mail.Store.PersistentDataCallbacks; import com.android.email.mail.internet.MimeBodyPart; import com.android.email.mail.internet.MimeHeader; import com.android.email.mail.internet.MimeMessage; import com.android.email.mail.internet.MimeMultipart; import com.android.email.mail.internet.MimeUtility; import com.android.email.mail.internet.TextBody; import org.apache.commons.io.IOUtils; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.net.Uri; import android.util.Log; import android.util.Base64; import android.util.Base64OutputStream; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Date; import java.util.UUID; /** * <pre> * Implements a SQLite database backed local store for Messages. * </pre> */ public class LocalStore extends Store implements PersistentDataCallbacks { /** * History of database revisions. * * db version Shipped in Notes * ---------- ---------- ----- * 18 pre-1.0 Development versions. No upgrade path. * 18 1.0, 1.1 1.0 Release version. * 19 - Added message_id column to messages table. * 20 1.5 Added content_id column to attachments table. * 21 - Added remote_store_data table * 22 - Added store_flag_1 and store_flag_2 columns to messages table. * 23 - Added flag_downloaded_full, flag_downloaded_partial, flag_deleted * columns to message table. * 24 - Added x_headers to messages table. */ private static final int DB_VERSION = 24; private static final Flag[] PERMANENT_FLAGS = { Flag.DELETED, Flag.X_DESTROYED, Flag.SEEN }; private String mPath; private SQLiteDatabase mDb; private File mAttachmentsDir; private Context mContext; private int mVisibleLimitDefault = -1; /** * Static named constructor. */ public static LocalStore newInstance(String uri, Context context, PersistentDataCallbacks callbacks) throws MessagingException { return new LocalStore(uri, context); } /** * @param uri local://localhost/path/to/database/uuid.db */ private LocalStore(String _uri, Context context) throws MessagingException { mContext = context; URI uri = null; try { uri = new URI(_uri); } catch (Exception e) { throw new MessagingException("Invalid uri for LocalStore"); } if (!uri.getScheme().equals("local")) { throw new MessagingException("Invalid scheme"); } mPath = uri.getPath(); File parentDir = new File(mPath).getParentFile(); if (!parentDir.exists()) { parentDir.mkdirs(); } mDb = SQLiteDatabase.openOrCreateDatabase(mPath, null); int oldVersion = mDb.getVersion(); /* * TODO we should have more sophisticated way to upgrade database. */ if (oldVersion != DB_VERSION) { if (Email.LOGD) { Log.v(Email.LOG_TAG, String.format("Upgrading database from %d to %d", oldVersion, DB_VERSION)); } if (oldVersion < 18) { /** * Missing or old: Create up-to-date tables */ mDb.execSQL("DROP TABLE IF EXISTS folders"); mDb.execSQL("CREATE TABLE folders (id INTEGER PRIMARY KEY, name TEXT, " + "last_updated INTEGER, unread_count INTEGER, visible_limit INTEGER)"); mDb.execSQL("DROP TABLE IF EXISTS messages"); mDb.execSQL("CREATE TABLE messages (id INTEGER PRIMARY KEY, folder_id INTEGER, " + "uid TEXT, subject TEXT, date INTEGER, flags TEXT, sender_list TEXT, " + "to_list TEXT, cc_list TEXT, bcc_list TEXT, reply_to_list TEXT, " + "html_content TEXT, text_content TEXT, attachment_count INTEGER, " + "internal_date INTEGER, message_id TEXT, store_flag_1 INTEGER, " + "store_flag_2 INTEGER, flag_downloaded_full INTEGER," + "flag_downloaded_partial INTEGER, flag_deleted INTEGER, x_headers TEXT)"); mDb.execSQL("DROP TABLE IF EXISTS attachments"); mDb.execSQL("CREATE TABLE attachments (id INTEGER PRIMARY KEY, message_id INTEGER," + "store_data TEXT, content_uri TEXT, size INTEGER, name TEXT," + "mime_type TEXT, content_id TEXT)"); mDb.execSQL("DROP TABLE IF EXISTS pending_commands"); mDb.execSQL("CREATE TABLE pending_commands " + "(id INTEGER PRIMARY KEY, command TEXT, arguments TEXT)"); addRemoteStoreDataTable(); addFolderDeleteTrigger(); mDb.execSQL("DROP TRIGGER IF EXISTS delete_message"); mDb.execSQL("CREATE TRIGGER delete_message BEFORE DELETE ON messages BEGIN DELETE FROM attachments WHERE old.id = message_id; END;"); mDb.setVersion(DB_VERSION); } else { if (oldVersion < 19) { /** * Upgrade 18 to 19: add message_id to messages table */ mDb.execSQL("ALTER TABLE messages ADD COLUMN message_id TEXT;"); mDb.setVersion(19); } if (oldVersion < 20) { /** * Upgrade 19 to 20: add content_id to attachments table */ mDb.execSQL("ALTER TABLE attachments ADD COLUMN content_id TEXT;"); mDb.setVersion(20); } if (oldVersion < 21) { /** * Upgrade 20 to 21: add remote_store_data and update triggers to match */ addRemoteStoreDataTable(); addFolderDeleteTrigger(); mDb.setVersion(21); } if (oldVersion < 22) { /** * Upgrade 21 to 22: add store_flag_1 and store_flag_2 to messages table */ mDb.execSQL("ALTER TABLE messages ADD COLUMN store_flag_1 INTEGER;"); mDb.execSQL("ALTER TABLE messages ADD COLUMN store_flag_2 INTEGER;"); mDb.setVersion(22); } if (oldVersion < 23) { /** * Upgrade 22 to 23: add flag_downloaded_full & flag_downloaded_partial * and flag_deleted columns to message table *and upgrade existing messages*. */ mDb.beginTransaction(); try { mDb.execSQL( "ALTER TABLE messages ADD COLUMN flag_downloaded_full INTEGER;"); mDb.execSQL( "ALTER TABLE messages ADD COLUMN flag_downloaded_partial INTEGER;"); mDb.execSQL( "ALTER TABLE messages ADD COLUMN flag_deleted INTEGER;"); migrateMessageFlags(); mDb.setVersion(23); mDb.setTransactionSuccessful(); } finally { mDb.endTransaction(); } } if (oldVersion < 24) { /** * Upgrade 23 to 24: add x_headers to messages table */ mDb.execSQL("ALTER TABLE messages ADD COLUMN x_headers TEXT;"); mDb.setVersion(24); } } if (mDb.getVersion() != DB_VERSION) { throw new Error("Database upgrade failed!"); } } mAttachmentsDir = new File(mPath + "_att"); if (!mAttachmentsDir.exists()) { mAttachmentsDir.mkdirs(); } } /** * Common code to add the remote_store_data table */ private void addRemoteStoreDataTable() { mDb.execSQL("DROP TABLE IF EXISTS remote_store_data"); mDb.execSQL("CREATE TABLE remote_store_data (" + "id INTEGER PRIMARY KEY, folder_id INTEGER, data_key TEXT, data TEXT, " + "UNIQUE (folder_id, data_key) ON CONFLICT REPLACE" + ")"); } /** * Common code to add folder delete trigger */ private void addFolderDeleteTrigger() { mDb.execSQL("DROP TRIGGER IF EXISTS delete_folder"); mDb.execSQL("CREATE TRIGGER delete_folder " + "BEFORE DELETE ON folders " + "BEGIN " + "DELETE FROM messages WHERE old.id = folder_id; " + "DELETE FROM remote_store_data WHERE old.id = folder_id; " + "END;"); } /** * When upgrading from 22 to 23, we have to move any flags "X_DOWNLOADED_FULL" or * "X_DOWNLOADED_PARTIAL" or "DELETED" from the old string-based storage to their own columns. * * Note: Caller should open a db transaction around this */ private void migrateMessageFlags() { Cursor cursor = mDb.query("messages", new String[] { "id", "flags" }, null, null, null, null, null); try { int columnId = cursor.getColumnIndexOrThrow("id"); int columnFlags = cursor.getColumnIndexOrThrow("flags"); while (cursor.moveToNext()) { String oldFlags = cursor.getString(columnFlags); ContentValues values = new ContentValues(); int newFlagDlFull = 0; int newFlagDlPartial = 0; int newFlagDeleted = 0; if (oldFlags != null) { if (oldFlags.contains(Flag.X_DOWNLOADED_FULL.toString())) { newFlagDlFull = 1; } if (oldFlags.contains(Flag.X_DOWNLOADED_PARTIAL.toString())) { newFlagDlPartial = 1; } if (oldFlags.contains(Flag.DELETED.toString())) { newFlagDeleted = 1; } } // Always commit the new flags. // Note: We don't have to pay the cost of rewriting the old string, // because the old flag will be ignored, and will eventually be overwritten // anyway. values.put("flag_downloaded_full", newFlagDlFull); values.put("flag_downloaded_partial", newFlagDlPartial); values.put("flag_deleted", newFlagDeleted); int rowId = cursor.getInt(columnId); mDb.update("messages", values, "id=" + rowId, null); } } finally { cursor.close(); } } @Override public Folder getFolder(String name) throws MessagingException { return new LocalFolder(name); } // TODO this takes about 260-300ms, seems slow. @Override public Folder[] getPersonalNamespaces() throws MessagingException { ArrayList<Folder> folders = new ArrayList<Folder>(); Cursor cursor = null; try { cursor = mDb.rawQuery("SELECT name FROM folders", null); while (cursor.moveToNext()) { folders.add(new LocalFolder(cursor.getString(0))); } } finally { if (cursor != null) { cursor.close(); } } return folders.toArray(new Folder[] {}); } @Override public void checkSettings() throws MessagingException { } /** * Local store only: Allow it to be closed. This is necessary for the account upgrade process * because we open and close each database a few times as we proceed. */ public void close() { try { mDb.close(); mDb = null; } catch (Exception e) { // Log and discard. This is best-effort, and database finalizers will try again. Log.d(Email.LOG_TAG, "Caught exception while closing localstore db: " + e); } } /** * Delete the entire Store and it's backing database. */ @Override public void delete() { try { mDb.close(); } catch (Exception e) { } try{ File[] attachments = mAttachmentsDir.listFiles(); for (File attachment : attachments) { if (attachment.exists()) { attachment.delete(); } } if (mAttachmentsDir.exists()) { mAttachmentsDir.delete(); } } catch (Exception e) { } try { new File(mPath).delete(); } catch (Exception e) { } } /** * Report # of attachments (for migration estimates only - catches all exceptions and * just returns zero) */ public int getStoredAttachmentCount() { try{ File[] attachments = mAttachmentsDir.listFiles(); return attachments.length; } catch (Exception e) { return 0; } } /** * Deletes all cached attachments for the entire store. */ public int pruneCachedAttachments() throws MessagingException { int prunedCount = 0; File[] files = mAttachmentsDir.listFiles(); for (File file : files) { if (file.exists()) { try { Cursor cursor = null; try { cursor = mDb.query( "attachments", new String[] { "store_data" }, "id = ?", new String[] { file.getName() }, null, null, null); if (cursor.moveToNext()) { if (cursor.getString(0) == null) { /* * If the attachment has no store data it is not recoverable, so * we won't delete it. */ continue; } } } finally { if (cursor != null) { cursor.close(); } } ContentValues cv = new ContentValues(); cv.putNull("content_uri"); mDb.update("attachments", cv, "id = ?", new String[] { file.getName() }); } catch (Exception e) { /* * If the row has gone away before we got to mark it not-downloaded that's * okay. */ } if (!file.delete()) { file.deleteOnExit(); } prunedCount++; } } return prunedCount; } /** * Set the visible limit for all folders in a given store. * * @param visibleLimit the value to write to all folders. -1 may also be used as a marker. */ public void resetVisibleLimits(int visibleLimit) { mVisibleLimitDefault = visibleLimit; // used for future Folder.create ops ContentValues cv = new ContentValues(); cv.put("visible_limit", Integer.toString(visibleLimit)); mDb.update("folders", cv, null, null); } public ArrayList<PendingCommand> getPendingCommands() { Cursor cursor = null; try { cursor = mDb.query("pending_commands", new String[] { "id", "command", "arguments" }, null, null, null, null, "id ASC"); ArrayList<PendingCommand> commands = new ArrayList<PendingCommand>(); while (cursor.moveToNext()) { PendingCommand command = new PendingCommand(); command.mId = cursor.getLong(0); command.command = cursor.getString(1); String arguments = cursor.getString(2); command.arguments = arguments.split(","); for (int i = 0; i < command.arguments.length; i++) { command.arguments[i] = Utility.fastUrlDecode(command.arguments[i]); } commands.add(command); } return commands; } finally { if (cursor != null) { cursor.close(); } } } public void addPendingCommand(PendingCommand command) { try { for (int i = 0; i < command.arguments.length; i++) { command.arguments[i] = URLEncoder.encode(command.arguments[i], "UTF-8"); } ContentValues cv = new ContentValues(); cv.put("command", command.command); cv.put("arguments", Utility.combine(command.arguments, ',')); mDb.insert("pending_commands", "command", cv); } catch (UnsupportedEncodingException usee) { throw new Error("Aparently UTF-8 has been lost to the annals of history."); } } public void removePendingCommand(PendingCommand command) { mDb.delete("pending_commands", "id = ?", new String[] { Long.toString(command.mId) }); } public static class PendingCommand { private long mId; public String command; public String[] arguments; @Override public String toString() { StringBuffer sb = new StringBuffer(); sb.append(command); sb.append("\n"); for (String argument : arguments) { sb.append(" "); sb.append(argument); sb.append("\n"); } return sb.toString(); } } /** * LocalStore-only function to get the callbacks API */ public PersistentDataCallbacks getPersistentCallbacks() throws MessagingException { return this; } public String getPersistentString(String key, String defaultValue) { return getPersistentString(-1, key, defaultValue); } public void setPersistentString(String key, String value) { setPersistentString(-1, key, value); } /** * Common implementation of getPersistentString * @param folderId The id of the associated folder, or -1 for "store" values * @param key The key * @param defaultValue The value to return if the row is not found * @return The row data or the default */ private String getPersistentString(long folderId, String key, String defaultValue) { String result = defaultValue; Cursor cursor = null; try { cursor = mDb.query("remote_store_data", new String[] { "data" }, "folder_id = ? AND data_key = ?", new String[] { Long.toString(folderId), key }, null, null, null); if (cursor != null && cursor.moveToNext()) { result = cursor.getString(0); } } finally { if (cursor != null) { cursor.close(); } } return result; } /** * Common implementation of setPersistentString * @param folderId The id of the associated folder, or -1 for "store" values * @param key The key * @param value The value to store */ private void setPersistentString(long folderId, String key, String value) { ContentValues cv = new ContentValues(); cv.put("folder_id", Long.toString(folderId)); cv.put("data_key", key); cv.put("data", value); // Note: Table has on-conflict-replace rule mDb.insert("remote_store_data", null, cv); } public class LocalFolder extends Folder implements Folder.PersistentDataCallbacks { private String mName; private long mFolderId = -1; private int mUnreadMessageCount = -1; private int mVisibleLimit = -1; public LocalFolder(String name) { this.mName = name; } public long getId() { return mFolderId; } /** * This is just used by the internal callers */ private void open(OpenMode mode) throws MessagingException { open(mode, null); } @Override public void open(OpenMode mode, PersistentDataCallbacks callbacks) throws MessagingException { if (isOpen()) { return; } if (!exists()) { create(FolderType.HOLDS_MESSAGES); } Cursor cursor = null; try { cursor = mDb.rawQuery("SELECT id, unread_count, visible_limit FROM folders " + "where folders.name = ?", new String[] { mName }); if (!cursor.moveToFirst()) { throw new MessagingException("Nonexistent folder"); } mFolderId = cursor.getInt(0); mUnreadMessageCount = cursor.getInt(1); mVisibleLimit = cursor.getInt(2); } finally { if (cursor != null) { cursor.close(); } } } @Override public boolean isOpen() { return mFolderId != -1; } @Override public OpenMode getMode() throws MessagingException { return OpenMode.READ_WRITE; } @Override public String getName() { return mName; } @Override public boolean exists() throws MessagingException { return Utility.arrayContains(getPersonalNamespaces(), this); } // LocalStore supports folder creation @Override public boolean canCreate(FolderType type) { return true; } @Override public boolean create(FolderType type) throws MessagingException { if (exists()) { throw new MessagingException("Folder " + mName + " already exists."); } mDb.execSQL("INSERT INTO folders (name, visible_limit) VALUES (?, ?)", new Object[] { mName, mVisibleLimitDefault }); return true; } @Override public void close(boolean expunge) throws MessagingException { if (expunge) { expunge(); } mFolderId = -1; } @Override public int getMessageCount() throws MessagingException { return getMessageCount(null, null); } /** * Return number of messages based on the state of the flags. * * @param setFlags The flags that should be set for a message to be selected (null ok) * @param clearFlags The flags that should be clear for a message to be selected (null ok) * @return The number of messages matching the desired flag states. * @throws MessagingException */ public int getMessageCount(Flag[] setFlags, Flag[] clearFlags) throws MessagingException { // Generate WHERE clause based on flags observed StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM messages WHERE "); buildFlagPredicates(sql, setFlags, clearFlags); sql.append("messages.folder_id = ?"); open(OpenMode.READ_WRITE); Cursor cursor = null; try { cursor = mDb.rawQuery( sql.toString(), new String[] { Long.toString(mFolderId) }); cursor.moveToFirst(); int messageCount = cursor.getInt(0); return messageCount; } finally { if (cursor != null) { cursor.close(); } } } @Override public int getUnreadMessageCount() throws MessagingException { if (!isOpen()) { // opening it will read all columns including mUnreadMessageCount open(OpenMode.READ_WRITE); } else { // already open. refresh from db in case another instance wrote to it Cursor cursor = null; try { cursor = mDb.rawQuery("SELECT unread_count FROM folders WHERE folders.name = ?", new String[] { mName }); if (!cursor.moveToFirst()) { throw new MessagingException("Nonexistent folder"); } mUnreadMessageCount = cursor.getInt(0); } finally { if (cursor != null) { cursor.close(); } } } return mUnreadMessageCount; } public void setUnreadMessageCount(int unreadMessageCount) throws MessagingException { open(OpenMode.READ_WRITE); mUnreadMessageCount = Math.max(0, unreadMessageCount); mDb.execSQL("UPDATE folders SET unread_count = ? WHERE id = ?", new Object[] { mUnreadMessageCount, mFolderId }); } public int getVisibleLimit() throws MessagingException { if (!isOpen()) { // opening it will read all columns including mVisibleLimit open(OpenMode.READ_WRITE); } else { // already open. refresh from db in case another instance wrote to it Cursor cursor = null; try { cursor = mDb.rawQuery( "SELECT visible_limit FROM folders WHERE folders.name = ?", new String[] { mName }); if (!cursor.moveToFirst()) { throw new MessagingException("Nonexistent folder"); } mVisibleLimit = cursor.getInt(0); } finally { if (cursor != null) { cursor.close(); } } } return mVisibleLimit; } public void setVisibleLimit(int visibleLimit) throws MessagingException { open(OpenMode.READ_WRITE); mVisibleLimit = visibleLimit; mDb.execSQL("UPDATE folders SET visible_limit = ? WHERE id = ?", new Object[] { mVisibleLimit, mFolderId }); } /** * Supports FetchProfile.Item.BODY and FetchProfile.Item.STRUCTURE */ @Override public void fetch(Message[] messages, FetchProfile fp, MessageRetrievalListener listener) throws MessagingException { open(OpenMode.READ_WRITE); if (fp.contains(FetchProfile.Item.BODY) || fp.contains(FetchProfile.Item.STRUCTURE)) { for (Message message : messages) { LocalMessage localMessage = (LocalMessage)message; Cursor cursor = null; localMessage.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "multipart/mixed"); MimeMultipart mp = new MimeMultipart(); mp.setSubType("mixed"); localMessage.setBody(mp); // If fetching the body, retrieve html & plaintext from DB. // If fetching structure, simply build placeholders for them. if (fp.contains(FetchProfile.Item.BODY)) { try { cursor = mDb.rawQuery("SELECT html_content, text_content FROM messages " + "WHERE id = ?", new String[] { Long.toString(localMessage.mId) }); cursor.moveToNext(); String htmlContent = cursor.getString(0); String textContent = cursor.getString(1); if (htmlContent != null) { TextBody body = new TextBody(htmlContent); MimeBodyPart bp = new MimeBodyPart(body, "text/html"); mp.addBodyPart(bp); } if (textContent != null) { TextBody body = new TextBody(textContent); MimeBodyPart bp = new MimeBodyPart(body, "text/plain"); mp.addBodyPart(bp); } } finally { if (cursor != null) { cursor.close(); } } } else { MimeBodyPart bp = new MimeBodyPart(); bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/html;\n charset=\"UTF-8\""); mp.addBodyPart(bp); bp = new MimeBodyPart(); bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain;\n charset=\"UTF-8\""); mp.addBodyPart(bp); } try { cursor = mDb.query( "attachments", new String[] { "id", "size", "name", "mime_type", "store_data", "content_uri", "content_id" }, "message_id = ?", new String[] { Long.toString(localMessage.mId) }, null, null, null); while (cursor.moveToNext()) { long id = cursor.getLong(0); int size = cursor.getInt(1); String name = cursor.getString(2); String type = cursor.getString(3); String storeData = cursor.getString(4); String contentUri = cursor.getString(5); String contentId = cursor.getString(6); Body body = null; if (contentUri != null) { body = new LocalAttachmentBody(Uri.parse(contentUri), mContext); } MimeBodyPart bp = new LocalAttachmentBodyPart(body, id); bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\n name=\"%s\"", type, name)); bp.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64"); bp.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, String.format("attachment;\n filename=\"%s\";\n size=%d", name, size)); bp.setHeader(MimeHeader.HEADER_CONTENT_ID, contentId); /* * HEADER_ANDROID_ATTACHMENT_STORE_DATA is a custom header we add to that * we can later pull the attachment from the remote store if neccesary. */ bp.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, storeData); mp.addBodyPart(bp); } } finally { if (cursor != null) { cursor.close(); } } } } } /** * The columns to select when calling populateMessageFromGetMessageCursor() */ private final String POPULATE_MESSAGE_SELECT_COLUMNS = "subject, sender_list, date, uid, flags, id, to_list, cc_list, " + "bcc_list, reply_to_list, attachment_count, internal_date, message_id, " + "store_flag_1, store_flag_2, flag_downloaded_full, flag_downloaded_partial, " + "flag_deleted, x_headers"; /** * Populate a message from a cursor with the following columns: * * 0 subject * 1 from address * 2 date (long) * 3 uid * 4 flag list (older flags - comma-separated string) * 5 local id (long) * 6 to addresses * 7 cc addresses * 8 bcc addresses * 9 reply-to address * 10 attachment count (int) * 11 internal date (long) * 12 message id (from Mime headers) * 13 store flag 1 * 14 store flag 2 * 15 flag "downloaded full" * 16 flag "downloaded partial" * 17 flag "deleted" * 18 extended headers ("\r\n"-separated string) */ private void populateMessageFromGetMessageCursor(LocalMessage message, Cursor cursor) throws MessagingException{ message.setSubject(cursor.getString(0) == null ? "" : cursor.getString(0)); Address[] from = Address.legacyUnpack(cursor.getString(1)); if (from.length > 0) { message.setFrom(from[0]); } message.setSentDate(new Date(cursor.getLong(2))); message.setUid(cursor.getString(3)); String flagList = cursor.getString(4); if (flagList != null && flagList.length() > 0) { String[] flags = flagList.split(","); try { for (String flag : flags) { message.setFlagInternal(Flag.valueOf(flag.toUpperCase()), true); } } catch (Exception e) { } } message.mId = cursor.getLong(5); message.setRecipients(RecipientType.TO, Address.legacyUnpack(cursor.getString(6))); message.setRecipients(RecipientType.CC, Address.legacyUnpack(cursor.getString(7))); message.setRecipients(RecipientType.BCC, Address.legacyUnpack(cursor.getString(8))); message.setReplyTo(Address.legacyUnpack(cursor.getString(9))); message.mAttachmentCount = cursor.getInt(10); message.setInternalDate(new Date(cursor.getLong(11))); message.setMessageId(cursor.getString(12)); message.setFlagInternal(Flag.X_STORE_1, (0 != cursor.getInt(13))); message.setFlagInternal(Flag.X_STORE_2, (0 != cursor.getInt(14))); message.setFlagInternal(Flag.X_DOWNLOADED_FULL, (0 != cursor.getInt(15))); message.setFlagInternal(Flag.X_DOWNLOADED_PARTIAL, (0 != cursor.getInt(16))); message.setFlagInternal(Flag.DELETED, (0 != cursor.getInt(17))); message.setExtendedHeaders(cursor.getString(18)); } @Override public Message[] getMessages(int start, int end, MessageRetrievalListener listener) throws MessagingException { open(OpenMode.READ_WRITE); throw new MessagingException( "LocalStore.getMessages(int, int, MessageRetrievalListener) not yet implemented"); } @Override public Message getMessage(String uid) throws MessagingException { open(OpenMode.READ_WRITE); LocalMessage message = new LocalMessage(uid, this); Cursor cursor = null; try { cursor = mDb.rawQuery( "SELECT " + POPULATE_MESSAGE_SELECT_COLUMNS + " FROM messages" + " WHERE uid = ? AND folder_id = ?", new String[] { message.getUid(), Long.toString(mFolderId) }); if (!cursor.moveToNext()) { return null; } populateMessageFromGetMessageCursor(message, cursor); } finally { if (cursor != null) { cursor.close(); } } return message; } @Override public Message[] getMessages(MessageRetrievalListener listener) throws MessagingException { open(OpenMode.READ_WRITE); ArrayList<Message> messages = new ArrayList<Message>(); Cursor cursor = null; try { cursor = mDb.rawQuery( "SELECT " + POPULATE_MESSAGE_SELECT_COLUMNS + " FROM messages" + " WHERE folder_id = ?", new String[] { Long.toString(mFolderId) }); while (cursor.moveToNext()) { LocalMessage message = new LocalMessage(null, this); populateMessageFromGetMessageCursor(message, cursor); messages.add(message); } } finally { if (cursor != null) { cursor.close(); } } return messages.toArray(new Message[] {}); } @Override public Message[] getMessages(String[] uids, MessageRetrievalListener listener) throws MessagingException { open(OpenMode.READ_WRITE); if (uids == null) { return getMessages(listener); } ArrayList<Message> messages = new ArrayList<Message>(); for (String uid : uids) { messages.add(getMessage(uid)); } return messages.toArray(new Message[] {}); } /** * Return a set of messages based on the state of the flags. * * @param setFlags The flags that should be set for a message to be selected (null ok) * @param clearFlags The flags that should be clear for a message to be selected (null ok) * @param listener * @return A list of messages matching the desired flag states. * @throws MessagingException */ @Override public Message[] getMessages(Flag[] setFlags, Flag[] clearFlags, MessageRetrievalListener listener) throws MessagingException { // Generate WHERE clause based on flags observed StringBuilder sql = new StringBuilder( "SELECT " + POPULATE_MESSAGE_SELECT_COLUMNS + " FROM messages" + " WHERE "); buildFlagPredicates(sql, setFlags, clearFlags); sql.append("folder_id = ?"); open(OpenMode.READ_WRITE); ArrayList<Message> messages = new ArrayList<Message>(); Cursor cursor = null; try { cursor = mDb.rawQuery( sql.toString(), new String[] { Long.toString(mFolderId) }); while (cursor.moveToNext()) { LocalMessage message = new LocalMessage(null, this); populateMessageFromGetMessageCursor(message, cursor); messages.add(message); } } finally { if (cursor != null) { cursor.close(); } } return messages.toArray(new Message[] {}); } /* * Build SQL where predicates expression from set and clear flag arrays. */ private void buildFlagPredicates(StringBuilder sql, Flag[] setFlags, Flag[] clearFlags) throws MessagingException { if (setFlags != null) { for (Flag flag : setFlags) { if (flag == Flag.X_STORE_1) { sql.append("store_flag_1 = 1 AND "); } else if (flag == Flag.X_STORE_2) { sql.append("store_flag_2 = 1 AND "); } else if (flag == Flag.X_DOWNLOADED_FULL) { sql.append("flag_downloaded_full = 1 AND "); } else if (flag == Flag.X_DOWNLOADED_PARTIAL) { sql.append("flag_downloaded_partial = 1 AND "); } else if (flag == Flag.DELETED) { sql.append("flag_deleted = 1 AND "); } else { throw new MessagingException("Unsupported flag " + flag); } } } if (clearFlags != null) { for (Flag flag : clearFlags) { if (flag == Flag.X_STORE_1) { sql.append("store_flag_1 = 0 AND "); } else if (flag == Flag.X_STORE_2) { sql.append("store_flag_2 = 0 AND "); } else if (flag == Flag.X_DOWNLOADED_FULL) { sql.append("flag_downloaded_full = 0 AND "); } else if (flag == Flag.X_DOWNLOADED_PARTIAL) { sql.append("flag_downloaded_partial = 0 AND "); } else if (flag == Flag.DELETED) { sql.append("flag_deleted = 0 AND "); } else { throw new MessagingException("Unsupported flag " + flag); } } } } @Override public void copyMessages(Message[] msgs, Folder folder, MessageUpdateCallbacks callbacks) throws MessagingException { if (!(folder instanceof LocalFolder)) { throw new MessagingException("copyMessages called with incorrect Folder"); } ((LocalFolder) folder).appendMessages(msgs, true); } /** * The method differs slightly from the contract; If an incoming message already has a uid * assigned and it matches the uid of an existing message then this message will replace the * old message. It is implemented as a delete/insert. This functionality is used in saving * of drafts and re-synchronization of updated server messages. */ @Override public void appendMessages(Message[] messages) throws MessagingException { appendMessages(messages, false); } /** * The method differs slightly from the contract; If an incoming message already has a uid * assigned and it matches the uid of an existing message then this message will replace the * old message. It is implemented as a delete/insert. This functionality is used in saving * of drafts and re-synchronization of updated server messages. */ public void appendMessages(Message[] messages, boolean copy) throws MessagingException { open(OpenMode.READ_WRITE); for (Message message : messages) { if (!(message instanceof MimeMessage)) { throw new Error("LocalStore can only store Messages that extend MimeMessage"); } String uid = message.getUid(); if (uid == null) { message.setUid("Local" + UUID.randomUUID().toString()); } else { /* * The message may already exist in this Folder, so delete it first. */ deleteAttachments(message.getUid()); mDb.execSQL("DELETE FROM messages WHERE folder_id = ? AND uid = ?", new Object[] { mFolderId, message.getUid() }); } ArrayList<Part> viewables = new ArrayList<Part>(); ArrayList<Part> attachments = new ArrayList<Part>(); MimeUtility.collectParts(message, viewables, attachments); StringBuffer sbHtml = new StringBuffer(); StringBuffer sbText = new StringBuffer(); for (Part viewable : viewables) { try { String text = MimeUtility.getTextFromPart(viewable); /* * Anything with MIME type text/html will be stored as such. Anything * else will be stored as text/plain. */ if (viewable.getMimeType().equalsIgnoreCase("text/html")) { sbHtml.append(text); } else { sbText.append(text); } } catch (Exception e) { throw new MessagingException("Unable to get text for message part", e); } } try { ContentValues cv = new ContentValues(); cv.put("uid", message.getUid()); cv.put("subject", message.getSubject()); cv.put("sender_list", Address.legacyPack(message.getFrom())); cv.put("date", message.getSentDate() == null ? System.currentTimeMillis() : message.getSentDate().getTime()); cv.put("flags", makeFlagsString(message)); cv.put("folder_id", mFolderId); cv.put("to_list", Address.legacyPack(message.getRecipients(RecipientType.TO))); cv.put("cc_list", Address.legacyPack(message.getRecipients(RecipientType.CC))); cv.put("bcc_list", Address.legacyPack( message.getRecipients(RecipientType.BCC))); cv.put("html_content", sbHtml.length() > 0 ? sbHtml.toString() : null); cv.put("text_content", sbText.length() > 0 ? sbText.toString() : null); cv.put("reply_to_list", Address.legacyPack(message.getReplyTo())); cv.put("attachment_count", attachments.size()); cv.put("internal_date", message.getInternalDate() == null ? System.currentTimeMillis() : message.getInternalDate().getTime()); cv.put("message_id", ((MimeMessage)message).getMessageId()); cv.put("store_flag_1", makeFlagNumeric(message, Flag.X_STORE_1)); cv.put("store_flag_2", makeFlagNumeric(message, Flag.X_STORE_2)); cv.put("flag_downloaded_full", makeFlagNumeric(message, Flag.X_DOWNLOADED_FULL)); cv.put("flag_downloaded_partial", makeFlagNumeric(message, Flag.X_DOWNLOADED_PARTIAL)); cv.put("flag_deleted", makeFlagNumeric(message, Flag.DELETED)); cv.put("x_headers", ((MimeMessage) message).getExtendedHeaders()); long messageId = mDb.insert("messages", "uid", cv); for (Part attachment : attachments) { saveAttachment(messageId, attachment, copy); } } catch (Exception e) { throw new MessagingException("Error appending message", e); } } } /** * Update the given message in the LocalStore without first deleting the existing * message (contrast with appendMessages). This method is used to store changes * to the given message while updating attachments and not removing existing * attachment data. * TODO In the future this method should be combined with appendMessages since the Message * contains enough data to decide what to do. * @param message * @throws MessagingException */ public void updateMessage(LocalMessage message) throws MessagingException { open(OpenMode.READ_WRITE); ArrayList<Part> viewables = new ArrayList<Part>(); ArrayList<Part> attachments = new ArrayList<Part>(); MimeUtility.collectParts(message, viewables, attachments); StringBuffer sbHtml = new StringBuffer(); StringBuffer sbText = new StringBuffer(); for (int i = 0, count = viewables.size(); i < count; i++) { Part viewable = viewables.get(i); try { String text = MimeUtility.getTextFromPart(viewable); /* * Anything with MIME type text/html will be stored as such. Anything * else will be stored as text/plain. */ if (viewable.getMimeType().equalsIgnoreCase("text/html")) { sbHtml.append(text); } else { sbText.append(text); } } catch (Exception e) { throw new MessagingException("Unable to get text for message part", e); } } try { mDb.execSQL("UPDATE messages SET " + "uid = ?, subject = ?, sender_list = ?, date = ?, flags = ?, " + "folder_id = ?, to_list = ?, cc_list = ?, bcc_list = ?, " + "html_content = ?, text_content = ?, reply_to_list = ?, " + "attachment_count = ?, message_id = ?, store_flag_1 = ?, " + "store_flag_2 = ?, flag_downloaded_full = ?, " + "flag_downloaded_partial = ?, flag_deleted = ?, x_headers = ? " + "WHERE id = ?", new Object[] { message.getUid(), message.getSubject(), Address.legacyPack(message.getFrom()), message.getSentDate() == null ? System .currentTimeMillis() : message.getSentDate() .getTime(), makeFlagsString(message), mFolderId, Address.legacyPack(message .getRecipients(RecipientType.TO)), Address.legacyPack(message .getRecipients(RecipientType.CC)), Address.legacyPack(message .getRecipients(RecipientType.BCC)), sbHtml.length() > 0 ? sbHtml.toString() : null, sbText.length() > 0 ? sbText.toString() : null, Address.legacyPack(message.getReplyTo()), attachments.size(), message.getMessageId(), makeFlagNumeric(message, Flag.X_STORE_1), makeFlagNumeric(message, Flag.X_STORE_2), makeFlagNumeric(message, Flag.X_DOWNLOADED_FULL), makeFlagNumeric(message, Flag.X_DOWNLOADED_PARTIAL), makeFlagNumeric(message, Flag.DELETED), message.getExtendedHeaders(), message.mId }); for (int i = 0, count = attachments.size(); i < count; i++) { Part attachment = attachments.get(i); saveAttachment(message.mId, attachment, false); } } catch (Exception e) { throw new MessagingException("Error appending message", e); } } /** * @param messageId * @param attachment * @param attachmentId -1 to create a new attachment or >= 0 to update an existing * @throws IOException * @throws MessagingException */ private void saveAttachment(long messageId, Part attachment, boolean saveAsNew) throws IOException, MessagingException { long attachmentId = -1; Uri contentUri = null; int size = -1; File tempAttachmentFile = null; if ((!saveAsNew) && (attachment instanceof LocalAttachmentBodyPart)) { attachmentId = ((LocalAttachmentBodyPart) attachment).getAttachmentId(); } if (attachment.getBody() != null) { Body body = attachment.getBody(); if (body instanceof LocalAttachmentBody) { contentUri = ((LocalAttachmentBody) body).getContentUri(); } else { /* * If the attachment has a body we're expected to save it into the local store * so we copy the data into a cached attachment file. */ InputStream in = attachment.getBody().getInputStream(); tempAttachmentFile = File.createTempFile("att", null, mAttachmentsDir); FileOutputStream out = new FileOutputStream(tempAttachmentFile); size = IOUtils.copy(in, out); in.close(); out.close(); } } if (size == -1) { /* * If the attachment is not yet downloaded see if we can pull a size * off the Content-Disposition. */ String disposition = attachment.getDisposition(); if (disposition != null) { String s = MimeUtility.getHeaderParameter(disposition, "size"); if (s != null) { size = Integer.parseInt(s); } } } if (size == -1) { size = 0; } String storeData = Utility.combine(attachment.getHeader( MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA), ','); String name = MimeUtility.getHeaderParameter(attachment.getContentType(), "name"); String contentId = attachment.getContentId(); if (attachmentId == -1) { ContentValues cv = new ContentValues(); cv.put("message_id", messageId); cv.put("content_uri", contentUri != null ? contentUri.toString() : null); cv.put("store_data", storeData); cv.put("size", size); cv.put("name", name); cv.put("mime_type", attachment.getMimeType()); cv.put("content_id", contentId); attachmentId = mDb.insert("attachments", "message_id", cv); } else { ContentValues cv = new ContentValues(); cv.put("content_uri", contentUri != null ? contentUri.toString() : null); cv.put("size", size); cv.put("content_id", contentId); cv.put("message_id", messageId); mDb.update( "attachments", cv, "id = ?", new String[] { Long.toString(attachmentId) }); } if (tempAttachmentFile != null) { File attachmentFile = new File(mAttachmentsDir, Long.toString(attachmentId)); tempAttachmentFile.renameTo(attachmentFile); // Doing this requires knowing the account id // contentUri = AttachmentProvider.getAttachmentUri( // new File(mPath).getName(), // attachmentId); attachment.setBody(new LocalAttachmentBody(contentUri, mContext)); ContentValues cv = new ContentValues(); cv.put("content_uri", contentUri != null ? contentUri.toString() : null); mDb.update( "attachments", cv, "id = ?", new String[] { Long.toString(attachmentId) }); } if (attachment instanceof LocalAttachmentBodyPart) { ((LocalAttachmentBodyPart) attachment).setAttachmentId(attachmentId); } } /** * Changes the stored uid of the given message (using it's internal id as a key) to * the uid in the message. * @param message */ public void changeUid(LocalMessage message) throws MessagingException { open(OpenMode.READ_WRITE); ContentValues cv = new ContentValues(); cv.put("uid", message.getUid()); mDb.update("messages", cv, "id = ?", new String[] { Long.toString(message.mId) }); } @Override public void setFlags(Message[] messages, Flag[] flags, boolean value) throws MessagingException { open(OpenMode.READ_WRITE); for (Message message : messages) { message.setFlags(flags, value); } } @Override public Message[] expunge() throws MessagingException { open(OpenMode.READ_WRITE); ArrayList<Message> expungedMessages = new ArrayList<Message>(); /* * epunge() doesn't do anything because deleted messages are saved for their uids * and really, really deleted messages are "Destroyed" and removed immediately. */ return expungedMessages.toArray(new Message[] {}); } @Override public void delete(boolean recurse) throws MessagingException { // We need to open the folder first to make sure we've got it's id open(OpenMode.READ_ONLY); Message[] messages = getMessages(null); for (Message message : messages) { deleteAttachments(message.getUid()); } mDb.execSQL("DELETE FROM folders WHERE id = ?", new Object[] { Long.toString(mFolderId), }); } @Override public boolean equals(Object o) { if (o instanceof LocalFolder) { return ((LocalFolder)o).mName.equals(mName); } return super.equals(o); } @Override public Flag[] getPermanentFlags() throws MessagingException { return PERMANENT_FLAGS; } private void deleteAttachments(String uid) throws MessagingException { open(OpenMode.READ_WRITE); Cursor messagesCursor = null; try { messagesCursor = mDb.query( "messages", new String[] { "id" }, "folder_id = ? AND uid = ?", new String[] { Long.toString(mFolderId), uid }, null, null, null); while (messagesCursor.moveToNext()) { long messageId = messagesCursor.getLong(0); Cursor attachmentsCursor = null; try { attachmentsCursor = mDb.query( "attachments", new String[] { "id" }, "message_id = ?", new String[] { Long.toString(messageId) }, null, null, null); while (attachmentsCursor.moveToNext()) { long attachmentId = attachmentsCursor.getLong(0); try{ File file = new File(mAttachmentsDir, Long.toString(attachmentId)); if (file.exists()) { file.delete(); } } catch (Exception e) { } } } finally { if (attachmentsCursor != null) { attachmentsCursor.close(); } } } } finally { if (messagesCursor != null) { messagesCursor.close(); } } } /** * Support for local persistence for our remote stores. * Will open the folder if necessary. */ public Folder.PersistentDataCallbacks getPersistentCallbacks() throws MessagingException { open(OpenMode.READ_WRITE); return this; } public String getPersistentString(String key, String defaultValue) { return LocalStore.this.getPersistentString(mFolderId, key, defaultValue); } public void setPersistentString(String key, String value) { LocalStore.this.setPersistentString(mFolderId, key, value); } /** * Transactionally combine a key/value and a complete message flags flip. Used * for setting sync bits in messages. * * Note: Not all flags are supported here and can only be changed with Message.setFlag(). * For example, Flag.DELETED has side effects (removes attachments). * * @param key * @param value * @param setFlags * @param clearFlags */ public void setPersistentStringAndMessageFlags(String key, String value, Flag[] setFlags, Flag[] clearFlags) throws MessagingException { mDb.beginTransaction(); try { // take care of folder persistence if (key != null) { setPersistentString(key, value); } // take care of flags ContentValues cv = new ContentValues(); if (setFlags != null) { for (Flag flag : setFlags) { if (flag == Flag.X_STORE_1) { cv.put("store_flag_1", 1); } else if (flag == Flag.X_STORE_2) { cv.put("store_flag_2", 1); } else if (flag == Flag.X_DOWNLOADED_FULL) { cv.put("flag_downloaded_full", 1); } else if (flag == Flag.X_DOWNLOADED_PARTIAL) { cv.put("flag_downloaded_partial", 1); } else { throw new MessagingException("Unsupported flag " + flag); } } } if (clearFlags != null) { for (Flag flag : clearFlags) { if (flag == Flag.X_STORE_1) { cv.put("store_flag_1", 0); } else if (flag == Flag.X_STORE_2) { cv.put("store_flag_2", 0); } else if (flag == Flag.X_DOWNLOADED_FULL) { cv.put("flag_downloaded_full", 0); } else if (flag == Flag.X_DOWNLOADED_PARTIAL) { cv.put("flag_downloaded_partial", 0); } else { throw new MessagingException("Unsupported flag " + flag); } } } mDb.update("messages", cv, "folder_id = ?", new String[] { Long.toString(mFolderId) }); mDb.setTransactionSuccessful(); } finally { mDb.endTransaction(); } } @Override public Message createMessage(String uid) throws MessagingException { return new LocalMessage(uid, this); } } public class LocalMessage extends MimeMessage { private long mId; private int mAttachmentCount; LocalMessage(String uid, Folder folder) throws MessagingException { this.mUid = uid; this.mFolder = folder; } public int getAttachmentCount() { return mAttachmentCount; } @Override public void parse(InputStream in) throws IOException, MessagingException { super.parse(in); } public void setFlagInternal(Flag flag, boolean set) throws MessagingException { super.setFlag(flag, set); } public long getId() { return mId; } @Override public void setFlag(Flag flag, boolean set) throws MessagingException { if (flag == Flag.DELETED && set) { /* * If a message is being marked as deleted we want to clear out it's content * and attachments as well. Delete will not actually remove the row since we need * to retain the uid for synchronization purposes. */ /* * Delete all of the messages' content to save space. */ mDb.execSQL( "UPDATE messages SET " + "subject = NULL, " + "sender_list = NULL, " + "date = NULL, " + "to_list = NULL, " + "cc_list = NULL, " + "bcc_list = NULL, " + "html_content = NULL, " + "text_content = NULL, " + "reply_to_list = NULL " + "WHERE id = ?", new Object[] { mId }); ((LocalFolder) mFolder).deleteAttachments(getUid()); /* * Delete all of the messages' attachments to save space. */ mDb.execSQL("DELETE FROM attachments WHERE id = ?", new Object[] { mId }); } else if (flag == Flag.X_DESTROYED && set) { ((LocalFolder) mFolder).deleteAttachments(getUid()); mDb.execSQL("DELETE FROM messages WHERE id = ?", new Object[] { mId }); } /* * Update the unread count on the folder. */ try { if (flag == Flag.DELETED || flag == Flag.X_DESTROYED || flag == Flag.SEEN) { LocalFolder folder = (LocalFolder)mFolder; if (set && !isSet(Flag.SEEN)) { folder.setUnreadMessageCount(folder.getUnreadMessageCount() - 1); } else if (!set && isSet(Flag.SEEN)) { folder.setUnreadMessageCount(folder.getUnreadMessageCount() + 1); } } } catch (MessagingException me) { Log.e(Email.LOG_TAG, "Unable to update LocalStore unread message count", me); throw new RuntimeException(me); } super.setFlag(flag, set); /* * Set the flags on the message. */ mDb.execSQL("UPDATE messages " + "SET flags = ?, store_flag_1 = ?, store_flag_2 = ?, " + "flag_downloaded_full = ?, flag_downloaded_partial = ?, flag_deleted = ? " + "WHERE id = ?", new Object[] { makeFlagsString(this), makeFlagNumeric(this, Flag.X_STORE_1), makeFlagNumeric(this, Flag.X_STORE_2), makeFlagNumeric(this, Flag.X_DOWNLOADED_FULL), makeFlagNumeric(this, Flag.X_DOWNLOADED_PARTIAL), makeFlagNumeric(this, Flag.DELETED), mId }); } } /** * Convert *old* flags to flags string. Some flags are kept in their own columns * (for selecting) and are not included here. * @param message The message containing the flag(s) * @return a comma-separated list of flags, to write into the "flags" column */ /* package */ String makeFlagsString(Message message) { StringBuilder sb = null; boolean nonEmpty = false; for (Flag flag : Flag.values()) { if (flag != Flag.X_STORE_1 && flag != Flag.X_STORE_2 && flag != Flag.X_DOWNLOADED_FULL && flag != Flag.X_DOWNLOADED_PARTIAL && flag != Flag.DELETED && message.isSet(flag)) { if (sb == null) { sb = new StringBuilder(); } if (nonEmpty) { sb.append(','); } sb.append(flag.toString()); nonEmpty = true; } } return (sb == null) ? null : sb.toString(); } /** * Convert flags to numeric form (0 or 1) for database storage. * @param message The message containing the flag of interest * @param flag The flag of interest * */ /* package */ int makeFlagNumeric(Message message, Flag flag) { return message.isSet(flag) ? 1 : 0; } public class LocalAttachmentBodyPart extends MimeBodyPart { private long mAttachmentId = -1; public LocalAttachmentBodyPart(Body body, long attachmentId) throws MessagingException { super(body); mAttachmentId = attachmentId; } /** * Returns the local attachment id of this body, or -1 if it is not stored. * @return */ public long getAttachmentId() { return mAttachmentId; } public void setAttachmentId(long attachmentId) { mAttachmentId = attachmentId; } @Override public String toString() { return "" + mAttachmentId; } } public static class LocalAttachmentBody implements Body { private Context mContext; private Uri mUri; public LocalAttachmentBody(Uri uri, Context context) { mContext = context; mUri = uri; } public InputStream getInputStream() throws MessagingException { try { return mContext.getContentResolver().openInputStream(mUri); } catch (FileNotFoundException fnfe) { /* * Since it's completely normal for us to try to serve up attachments that * have been blown away, we just return an empty stream. */ return new ByteArrayInputStream(new byte[0]); } catch (IOException ioe) { throw new MessagingException("Invalid attachment.", ioe); } } public void writeTo(OutputStream out) throws IOException, MessagingException { InputStream in = getInputStream(); Base64OutputStream base64Out = new Base64OutputStream( out, Base64.CRLF | Base64.NO_CLOSE); IOUtils.copy(in, base64Out); base64Out.close(); } public Uri getContentUri() { return mUri; } } /** * LocalStore does not have SettingActivity. */ @Override public Class<? extends android.app.Activity> getSettingActivityClass() { return null; } }