package com.fsck.k9.mail.store; 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.Serializable; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; import org.apache.commons.io.IOUtils; import android.app.Application; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.SharedPreferences; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteException; import android.net.Uri; import android.util.Log; import com.fsck.k9.Account; import com.fsck.k9.K9; import com.fsck.k9.Preferences; import com.fsck.k9.R; import com.fsck.k9.Account.MessageFormat; import com.fsck.k9.activity.Search; import com.fsck.k9.controller.MessageRemovalListener; import com.fsck.k9.controller.MessageRetrievalListener; import com.fsck.k9.helper.HtmlConverter; import com.fsck.k9.helper.StringUtils; import com.fsck.k9.helper.Utility; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Body; import com.fsck.k9.mail.BodyPart; import com.fsck.k9.mail.FetchProfile; import com.fsck.k9.mail.Flag; import com.fsck.k9.mail.Folder; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.Message.RecipientType; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.Part; import com.fsck.k9.mail.Store; import com.fsck.k9.mail.filter.Base64OutputStream; import com.fsck.k9.mail.internet.MimeBodyPart; import com.fsck.k9.mail.internet.MimeHeader; import com.fsck.k9.mail.internet.MimeMessage; import com.fsck.k9.mail.internet.MimeMultipart; import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.mail.internet.MimeUtility.ViewableContainer; import com.fsck.k9.mail.internet.TextBody; import com.fsck.k9.mail.store.LockableDatabase.DbCallback; import com.fsck.k9.mail.store.LockableDatabase.WrappedException; import com.fsck.k9.mail.store.StorageManager.StorageProvider; import com.fsck.k9.provider.AttachmentProvider; import com.fsck.k9.provider.EmailProvider; import com.fsck.k9.provider.EmailProvider.MessageColumns; import com.fsck.k9.search.LocalSearch; import com.fsck.k9.search.SearchSpecification.Attribute; import com.fsck.k9.search.SearchSpecification.Searchfield; import com.fsck.k9.search.SqlQueryBuilder; /** *
* Implements a SQLite database backed local store for Messages. **/ public class LocalStore extends Store implements Serializable { private static final long serialVersionUID = -5142141896809423072L; private static final Message[] EMPTY_MESSAGE_ARRAY = new Message[0]; private static final String[] EMPTY_STRING_ARRAY = new String[0]; private static final Flag[] EMPTY_FLAG_ARRAY = new Flag[0]; /* * a String containing the columns getMessages expects to work with * in the correct order. */ static private String GET_MESSAGES_COLS = "subject, sender_list, date, uid, flags, messages.id, to_list, cc_list, " + "bcc_list, reply_to_list, attachment_count, internal_date, messages.message_id, " + "folder_id, preview, threads.id, threads.root, deleted, read, flagged, answered, " + "forwarded "; static private String GET_FOLDER_COLS = "folders.id, name, SUM(read=0), visible_limit, last_updated, status, push_state, last_pushed, SUM(flagged), integrate, top_group, poll_class, push_class, display_class"; private static final String[] UID_CHECK_PROJECTION = { "uid" }; /** * Maximum number of UIDs to check for existence at once. * * @see LocalFolder#extractNewMessages(List) */ private static final int UID_CHECK_BATCH_SIZE = 500; /** * Maximum number of messages to perform flag updates on at once. * * @see #setFlag(List, Flag, boolean, boolean) */ private static final int FLAG_UPDATE_BATCH_SIZE = 500; /** * Maximum number of threads to perform flag updates on at once. * * @see #setFlagForThreads(List, Flag, boolean) */ private static final int THREAD_FLAG_UPDATE_BATCH_SIZE = 500; public static final int DB_VERSION = 48; public static String getColumnNameForFlag(Flag flag) { switch (flag) { case SEEN: { return MessageColumns.READ; } case FLAGGED: { return MessageColumns.FLAGGED; } case ANSWERED: { return MessageColumns.ANSWERED; } case FORWARDED: { return MessageColumns.FORWARDED; } default: { throw new IllegalArgumentException("Flag must be a special column flag"); } } } protected String uUid = null; private final Application mApplication; private LockableDatabase database; private ContentResolver mContentResolver; /** * local://localhost/path/to/database/uuid.db * This constructor is only used by {@link Store#getLocalInstance(Account, Application)} * @param account * @param application * @throws UnavailableStorageException if not {@link StorageProvider#isReady(Context)} */ public LocalStore(final Account account, final Application application) throws MessagingException { super(account); database = new LockableDatabase(application, account.getUuid(), new StoreSchemaDefinition()); mApplication = application; mContentResolver = application.getContentResolver(); database.setStorageProviderId(account.getLocalStorageProviderId()); uUid = account.getUuid(); database.open(); } public void switchLocalStorage(final String newStorageProviderId) throws MessagingException { database.switchProvider(newStorageProviderId); } protected SharedPreferences getPreferences() { return Preferences.getPreferences(mApplication).getPreferences(); } private class StoreSchemaDefinition implements LockableDatabase.SchemaDefinition { @Override public int getVersion() { return DB_VERSION; } @Override public void doDbUpgrade(final SQLiteDatabase db) { Log.i(K9.LOG_TAG, String.format("Upgrading database from version %d to version %d", db.getVersion(), DB_VERSION)); AttachmentProvider.clear(mApplication); db.beginTransaction(); try { try { // schema version 29 was when we moved to incremental updates // in the case of a new db or a < v29 db, we blow away and start from scratch if (db.getVersion() < 29) { db.execSQL("DROP TABLE IF EXISTS folders"); db.execSQL("CREATE TABLE folders (id INTEGER PRIMARY KEY, name TEXT, " + "last_updated INTEGER, unread_count INTEGER, visible_limit INTEGER, status TEXT, " + "push_state TEXT, last_pushed INTEGER, flagged_count INTEGER default 0, " + "integrate INTEGER, top_group INTEGER, poll_class TEXT, push_class TEXT, display_class TEXT" + ")"); db.execSQL("CREATE INDEX IF NOT EXISTS folder_name ON folders (name)"); db.execSQL("DROP TABLE IF EXISTS messages"); db.execSQL("CREATE TABLE messages (" + "id INTEGER PRIMARY KEY, " + "deleted INTEGER default 0, " + "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, " + "preview TEXT, " + "mime_type TEXT, "+ "normalized_subject_hash INTEGER, " + "empty INTEGER, " + "read INTEGER default 0, " + "flagged INTEGER default 0, " + "answered INTEGER default 0, " + "forwarded INTEGER default 0" + ")"); db.execSQL("DROP TABLE IF EXISTS headers"); db.execSQL("CREATE TABLE headers (id INTEGER PRIMARY KEY, message_id INTEGER, name TEXT, value TEXT)"); db.execSQL("CREATE INDEX IF NOT EXISTS header_folder ON headers (message_id)"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_uid ON messages (uid, folder_id)"); db.execSQL("DROP INDEX IF EXISTS msg_folder_id"); db.execSQL("DROP INDEX IF EXISTS msg_folder_id_date"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_folder_id_deleted_date ON messages (folder_id,deleted,internal_date)"); db.execSQL("DROP INDEX IF EXISTS msg_empty"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_empty ON messages (empty)"); db.execSQL("DROP INDEX IF EXISTS msg_read"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_read ON messages (read)"); db.execSQL("DROP INDEX IF EXISTS msg_flagged"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_flagged ON messages (flagged)"); db.execSQL("DROP TABLE IF EXISTS threads"); db.execSQL("CREATE TABLE threads (" + "id INTEGER PRIMARY KEY, " + "message_id INTEGER, " + "root INTEGER, " + "parent INTEGER" + ")"); db.execSQL("DROP INDEX IF EXISTS threads_message_id"); db.execSQL("CREATE INDEX IF NOT EXISTS threads_message_id ON threads (message_id)"); db.execSQL("DROP INDEX IF EXISTS threads_root"); db.execSQL("CREATE INDEX IF NOT EXISTS threads_root ON threads (root)"); db.execSQL("DROP INDEX IF EXISTS threads_parent"); db.execSQL("CREATE INDEX IF NOT EXISTS threads_parent ON threads (parent)"); db.execSQL("DROP TRIGGER IF EXISTS set_thread_root"); db.execSQL("CREATE TRIGGER set_thread_root " + "AFTER INSERT ON threads " + "BEGIN " + "UPDATE threads SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " + "END"); db.execSQL("DROP TABLE IF EXISTS attachments"); db.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, content_disposition TEXT)"); db.execSQL("DROP TABLE IF EXISTS pending_commands"); db.execSQL("CREATE TABLE pending_commands " + "(id INTEGER PRIMARY KEY, command TEXT, arguments TEXT)"); db.execSQL("DROP TRIGGER IF EXISTS delete_folder"); db.execSQL("CREATE TRIGGER delete_folder BEFORE DELETE ON folders BEGIN DELETE FROM messages WHERE old.id = folder_id; END;"); db.execSQL("DROP TRIGGER IF EXISTS delete_message"); db.execSQL("CREATE TRIGGER delete_message BEFORE DELETE ON messages BEGIN DELETE FROM attachments WHERE old.id = message_id; " + "DELETE FROM headers where old.id = message_id; END;"); } else { // in the case that we're starting out at 29 or newer, run all the needed updates if (db.getVersion() < 30) { try { db.execSQL("ALTER TABLE messages ADD deleted INTEGER default 0"); } catch (SQLiteException e) { if (! e.toString().startsWith("duplicate column name: deleted")) { throw e; } } } if (db.getVersion() < 31) { db.execSQL("DROP INDEX IF EXISTS msg_folder_id_date"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_folder_id_deleted_date ON messages (folder_id,deleted,internal_date)"); } if (db.getVersion() < 32) { db.execSQL("UPDATE messages SET deleted = 1 WHERE flags LIKE '%DELETED%'"); } if (db.getVersion() < 33) { try { db.execSQL("ALTER TABLE messages ADD preview TEXT"); } catch (SQLiteException e) { if (! e.toString().startsWith("duplicate column name: preview")) { throw e; } } } if (db.getVersion() < 34) { try { db.execSQL("ALTER TABLE folders ADD flagged_count INTEGER default 0"); } catch (SQLiteException e) { if (! e.getMessage().startsWith("duplicate column name: flagged_count")) { throw e; } } } if (db.getVersion() < 35) { try { db.execSQL("update messages set flags = replace(flags, 'X_NO_SEEN_INFO', 'X_BAD_FLAG')"); } catch (SQLiteException e) { Log.e(K9.LOG_TAG, "Unable to get rid of obsolete flag X_NO_SEEN_INFO", e); } } if (db.getVersion() < 36) { try { db.execSQL("ALTER TABLE attachments ADD content_id TEXT"); } catch (SQLiteException e) { Log.e(K9.LOG_TAG, "Unable to add content_id column to attachments"); } } if (db.getVersion() < 37) { try { db.execSQL("ALTER TABLE attachments ADD content_disposition TEXT"); } catch (SQLiteException e) { Log.e(K9.LOG_TAG, "Unable to add content_disposition column to attachments"); } } // Database version 38 is solely to prune cached attachments now that we clear them better if (db.getVersion() < 39) { try { db.execSQL("DELETE FROM headers WHERE id in (SELECT headers.id FROM headers LEFT JOIN messages ON headers.message_id = messages.id WHERE messages.id IS NULL)"); } catch (SQLiteException e) { Log.e(K9.LOG_TAG, "Unable to remove extra header data from the database"); } } // V40: Store the MIME type for a message. if (db.getVersion() < 40) { try { db.execSQL("ALTER TABLE messages ADD mime_type TEXT"); } catch (SQLiteException e) { Log.e(K9.LOG_TAG, "Unable to add mime_type column to messages"); } } if (db.getVersion() < 41) { try { db.execSQL("ALTER TABLE folders ADD integrate INTEGER"); db.execSQL("ALTER TABLE folders ADD top_group INTEGER"); db.execSQL("ALTER TABLE folders ADD poll_class TEXT"); db.execSQL("ALTER TABLE folders ADD push_class TEXT"); db.execSQL("ALTER TABLE folders ADD display_class TEXT"); } catch (SQLiteException e) { if (! e.getMessage().startsWith("duplicate column name:")) { throw e; } } Cursor cursor = null; try { SharedPreferences prefs = getPreferences(); cursor = db.rawQuery("SELECT id, name FROM folders", null); while (cursor.moveToNext()) { try { int id = cursor.getInt(0); String name = cursor.getString(1); update41Metadata(db, prefs, id, name); } catch (Exception e) { Log.e(K9.LOG_TAG, " error trying to ugpgrade a folder class", e); } } } catch (SQLiteException e) { Log.e(K9.LOG_TAG, "Exception while upgrading database to v41. folder classes may have vanished", e); } finally { Utility.closeQuietly(cursor); } } if (db.getVersion() == 41) { try { long startTime = System.currentTimeMillis(); SharedPreferences.Editor editor = getPreferences().edit(); List extends Folder > folders = getPersonalNamespaces(true); for (Folder folder : folders) { if (folder instanceof LocalFolder) { LocalFolder lFolder = (LocalFolder)folder; lFolder.save(editor); } } editor.commit(); long endTime = System.currentTimeMillis(); Log.i(K9.LOG_TAG, "Putting folder preferences for " + folders.size() + " folders back into Preferences took " + (endTime - startTime) + " ms"); } catch (Exception e) { Log.e(K9.LOG_TAG, "Could not replace Preferences in upgrade from DB_VERSION 41", e); } } if (db.getVersion() < 43) { try { // If folder "OUTBOX" (old, v3.800 - v3.802) exists, rename it to // "K9MAIL_INTERNAL_OUTBOX" (new) LocalFolder oldOutbox = new LocalFolder("OUTBOX"); if (oldOutbox.exists()) { ContentValues cv = new ContentValues(); cv.put("name", Account.OUTBOX); db.update("folders", cv, "name = ?", new String[] { "OUTBOX" }); Log.i(K9.LOG_TAG, "Renamed folder OUTBOX to " + Account.OUTBOX); } // Check if old (pre v3.800) localized outbox folder exists String localizedOutbox = K9.app.getString(R.string.special_mailbox_name_outbox); LocalFolder obsoleteOutbox = new LocalFolder(localizedOutbox); if (obsoleteOutbox.exists()) { // Get all messages from the localized outbox ... Message[] messages = obsoleteOutbox.getMessages(null, false); if (messages.length > 0) { // ... and move them to the drafts folder (we don't want to // surprise the user by sending potentially very old messages) LocalFolder drafts = new LocalFolder(mAccount.getDraftsFolderName()); obsoleteOutbox.moveMessages(messages, drafts); } // Now get rid of the localized outbox obsoleteOutbox.delete(); obsoleteOutbox.delete(true); } } catch (Exception e) { Log.e(K9.LOG_TAG, "Error trying to fix the outbox folders", e); } } if (db.getVersion() < 44) { try { db.execSQL("ALTER TABLE messages ADD thread_root INTEGER"); db.execSQL("ALTER TABLE messages ADD thread_parent INTEGER"); db.execSQL("ALTER TABLE messages ADD normalized_subject_hash INTEGER"); db.execSQL("ALTER TABLE messages ADD empty INTEGER"); } catch (SQLiteException e) { if (! e.getMessage().startsWith("duplicate column name:")) { throw e; } } } if (db.getVersion() < 45) { try { db.execSQL("DROP INDEX IF EXISTS msg_empty"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_empty ON messages (empty)"); db.execSQL("DROP INDEX IF EXISTS msg_thread_root"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_thread_root ON messages (thread_root)"); db.execSQL("DROP INDEX IF EXISTS msg_thread_parent"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_thread_parent ON messages (thread_parent)"); } catch (SQLiteException e) { if (! e.getMessage().startsWith("duplicate column name:")) { throw e; } } } if (db.getVersion() < 46) { db.execSQL("ALTER TABLE messages ADD read INTEGER default 0"); db.execSQL("ALTER TABLE messages ADD flagged INTEGER default 0"); db.execSQL("ALTER TABLE messages ADD answered INTEGER default 0"); db.execSQL("ALTER TABLE messages ADD forwarded INTEGER default 0"); String[] projection = { "id", "flags" }; ContentValues cv = new ContentValues(); List
null
.
* @param runnable What to do before setting {@link Flag#X_DOWNLOADED_FULL}. Never null
.
* @return The local version of the message. Never null
.
* @throws MessagingException
*/
public Message storeSmallMessage(final Message message, final Runnable runnable) throws MessagingException {
return database.execute(true, new DbCallbackFetches the most recent numeric UID value in this folder. This is used by * {@link com.fsck.k9.controller.MessagingController#shouldNotifyForMessage} to see if messages being * fetched are new and unread. Messages are "new" if they have a UID higher than the most recent UID prior * to synchronization.
* *This only works for protocols with numeric UIDs (like IMAP). For protocols with * alphanumeric UIDs (like POP), this method quietly fails and shouldNotifyForMessage() will * always notify for unread messages.
* *Once Issue 1072 has been fixed, this method and shouldNotifyForMessage() should be * updated to use internal dates rather than UIDs to determine new-ness. While this doesn't * solve things for POP (which doesn't have internal dates), we can likely use this as a * framework to examine send date in lieu of internal date.
* @throws MessagingException */ public void updateLastUid() throws MessagingException { Integer lastUid = database.execute(false, new DbCallback* At the time of this writing (2012-12-06) SQLite only supports around 1000 arguments. That's * why we have to split SQL statements with a large set of arguments into multiple SQL * statements each working on a subset of the arguments. *
* * @param selectionCallback * Supplies the argument set and the code to query/update the database. * @param batchSize * The maximum size of the selection set in each SQL statement. * * @throws MessagingException */ public void doBatchSetSelection(final BatchSetSelection selectionCallback, final int batchSize) throws MessagingException { final List
* The goal of this method is to be fast. Currently this means using as few SQL UPDATE
* statements as possible.
*
* @param messageIds
* A list of primary keys in the "messages" table.
* @param flag
* The flag to change. This must be a flag with a separate column in the database.
* @param newState
* {@code true}, if the flag should be set. {@code false}, otherwise.
*
* @throws MessagingException
*/
public void setFlag(final List
* The goal of this method is to be fast. Currently this means using as few SQL UPDATE
* statements as possible.
*
* @param threadRootIds
* A list of root thread IDs.
* @param flag
* The flag to change. This must be a flag with a separate column in the database.
* @param newState
* {@code true}, if the flag should be set. {@code false}, otherwise.
*
* @throws MessagingException
*/
public void setFlagForThreads(final List