package com.fsck.k9.mailstore; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Stack; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.net.Uri; import android.support.annotation.Nullable; import android.text.TextUtils; import timber.log.Timber; import com.fsck.k9.Account; import com.fsck.k9.K9; import com.fsck.k9.Preferences; import com.fsck.k9.controller.PendingCommandSerializer; import com.fsck.k9.controller.MessagingControllerCommands.PendingCommand; import com.fsck.k9.helper.Utility; import com.fsck.k9.mail.Body; import com.fsck.k9.mail.BodyPart; import com.fsck.k9.mail.FetchProfile; import com.fsck.k9.mail.FetchProfile.Item; import com.fsck.k9.mail.Flag; import com.fsck.k9.mail.Folder; import com.fsck.k9.mail.MessageRetrievalListener; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.Multipart; import com.fsck.k9.mail.Part; import com.fsck.k9.mail.Store; import com.fsck.k9.mailstore.LocalFolder.DataLocation; import com.fsck.k9.mailstore.LocalFolder.MoreMessages; import com.fsck.k9.mailstore.LockableDatabase.DbCallback; import com.fsck.k9.mailstore.LockableDatabase.WrappedException; import com.fsck.k9.mailstore.StorageManager.StorageProvider; import com.fsck.k9.message.extractors.AttachmentCounter; import com.fsck.k9.message.extractors.AttachmentInfoExtractor; import com.fsck.k9.message.extractors.MessageFulltextCreator; import com.fsck.k9.message.extractors.MessagePreviewCreator; import com.fsck.k9.preferences.Storage; 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; import org.apache.commons.io.IOUtils; import org.apache.james.mime4j.codec.Base64InputStream; import org.apache.james.mime4j.codec.QuotedPrintableInputStream; import org.apache.james.mime4j.util.MimeUtil; import org.openintents.openpgp.util.OpenPgpApi.OpenPgpDataSource; /** * <pre> * Implements a SQLite database backed local store for Messages. * </pre> */ public class LocalStore extends Store implements Serializable { private static final long serialVersionUID = -5142141896809423072L; static final String[] EMPTY_STRING_ARRAY = new String[0]; static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; /** * Lock objects indexed by account UUID. * * @see #getInstance(Account, Context) */ private static ConcurrentMap<String, Object> sAccountLocks = new ConcurrentHashMap<>(); /** * Local stores indexed by UUID because the Uri may change due to migration to/from SD-card. */ private static ConcurrentMap<String, LocalStore> sLocalStores = new ConcurrentHashMap<>(); /* * a String containing the columns getMessages expects to work with * in the correct order. */ static 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, message_part_id, messages.mime_type, preview_type, header "; static final String GET_FOLDER_COLS = "folders.id, name, visible_limit, last_updated, status, push_state, last_pushed, " + "integrate, top_group, poll_class, push_class, display_class, notify_class, more_messages"; static final int FOLDER_ID_INDEX = 0; static final int FOLDER_NAME_INDEX = 1; static final int FOLDER_VISIBLE_LIMIT_INDEX = 2; static final int FOLDER_LAST_CHECKED_INDEX = 3; static final int FOLDER_STATUS_INDEX = 4; static final int FOLDER_PUSH_STATE_INDEX = 5; static final int FOLDER_LAST_PUSHED_INDEX = 6; static final int FOLDER_INTEGRATE_INDEX = 7; static final int FOLDER_TOP_GROUP_INDEX = 8; static final int FOLDER_SYNC_CLASS_INDEX = 9; static final int FOLDER_PUSH_CLASS_INDEX = 10; static final int FOLDER_DISPLAY_CLASS_INDEX = 11; static final int FOLDER_NOTIFY_CLASS_INDEX = 12; static final int MORE_MESSAGES_INDEX = 13; static final String[] UID_CHECK_PROJECTION = { "uid" }; private static final String[] GET_ATTACHMENT_COLS = new String[] { "id", "root", "data_location", "encoding", "data" }; private static final int ATTACH_PART_ID_INDEX = 0; private static final int ATTACH_ROOT_INDEX = 1; private static final int ATTACH_LOCATION_INDEX = 2; private static final int ATTACH_ENCODING_INDEX = 3; private static final int ATTACH_DATA_INDEX = 4; /** * Maximum number of UIDs to check for existence at once. * * @see LocalFolder#extractNewMessages(List) */ static final int UID_CHECK_BATCH_SIZE = 500; /** * Maximum number of messages to perform flag updates on at once. * * @see #setFlag(List, Flag, 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 = 60; 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; final Context context; LockableDatabase database; private ContentResolver mContentResolver; private final Account mAccount; private final MessagePreviewCreator messagePreviewCreator; private final MessageFulltextCreator messageFulltextCreator; private final AttachmentCounter attachmentCounter; private final PendingCommandSerializer pendingCommandSerializer; final AttachmentInfoExtractor attachmentInfoExtractor; /** * local://localhost/path/to/database/uuid.db * This constructor is only used by {@link LocalStore#getInstance(Account, Context)} * @throws UnavailableStorageException if not {@link StorageProvider#isReady(Context)} */ private LocalStore(final Account account, final Context context) throws MessagingException { mAccount = account; database = new LockableDatabase(context, account.getUuid(), new StoreSchemaDefinition(this)); this.context = context; mContentResolver = context.getContentResolver(); database.setStorageProviderId(account.getLocalStorageProviderId()); uUid = account.getUuid(); messagePreviewCreator = MessagePreviewCreator.newInstance(); messageFulltextCreator = MessageFulltextCreator.newInstance(); attachmentCounter = AttachmentCounter.newInstance(); pendingCommandSerializer = PendingCommandSerializer.getInstance(); attachmentInfoExtractor = AttachmentInfoExtractor.getInstance(); database.open(); } /** * Get an instance of a local mail store. * * @throws UnavailableStorageException * if not {@link StorageProvider#isReady(Context)} */ public static LocalStore getInstance(Account account, Context context) throws MessagingException { String accountUuid = account.getUuid(); // Create new per-account lock object if necessary sAccountLocks.putIfAbsent(accountUuid, new Object()); // Use per-account locks so DatabaseUpgradeService always knows which account database is // currently upgraded. synchronized (sAccountLocks.get(accountUuid)) { LocalStore store = sLocalStores.get(accountUuid); if (store == null) { // Creating a LocalStore instance will create or upgrade the database if // necessary. This could take some time. store = new LocalStore(account, context); sLocalStores.put(accountUuid, store); } return store; } } public static void removeAccount(Account account) { try { removeInstance(account); } catch (Exception e) { Timber.e(e, "Failed to reset local store for account %s", account.getUuid()); } } private static void removeInstance(Account account) { String accountUuid = account.getUuid(); sLocalStores.remove(accountUuid); } public void switchLocalStorage(final String newStorageProviderId) throws MessagingException { database.switchProvider(newStorageProviderId); } Context getContext() { return context; } protected Account getAccount() { return mAccount; } protected Storage getStorage() { return Preferences.getPreferences(context).getStorage(); } public long getSize() throws MessagingException { final StorageManager storageManager = StorageManager.getInstance(context); final File attachmentDirectory = storageManager.getAttachmentDirectory(uUid, database.getStorageProviderId()); return database.execute(false, new DbCallback<Long>() { @Override public Long doDbWork(final SQLiteDatabase db) { final File[] files = attachmentDirectory.listFiles(); long attachmentLength = 0; if (files != null) { for (File file : files) { if (file.exists()) { attachmentLength += file.length(); } } } final File dbFile = storageManager.getDatabase(uUid, database.getStorageProviderId()); return dbFile.length() + attachmentLength; } }); } public void compact() throws MessagingException { if (K9.isDebug()) { Timber.i("Before compaction size = %d", getSize()); } database.execute(false, new DbCallback<Void>() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException { db.execSQL("VACUUM"); return null; } }); if (K9.isDebug()) { Timber.i("After compaction size = %d", getSize()); } } public void clear() throws MessagingException { if (K9.isDebug()) { Timber.i("Before prune size = %d", getSize()); } deleteAllMessageDataFromDisk(); if (K9.isDebug()) { Timber.i("After prune / before compaction size = %d", getSize()); Timber.i("Before clear folder count = %d", getFolderCount()); Timber.i("Before clear message count = %d", getMessageCount()); Timber.i("After prune / before clear size = %d", getSize()); } database.execute(false, new DbCallback<Void>() { @Override public Void doDbWork(final SQLiteDatabase db) { // We don't care about threads of deleted messages, so delete the whole table. db.delete("threads", null, null); // Don't delete deleted messages. They are essentially placeholders for UIDs of messages that have // been deleted locally. db.delete("messages", "deleted = 0", null); // We don't need the search data now either db.delete("messages_fulltext", null, null); return null; } }); compact(); if (K9.isDebug()) { Timber.i("After clear message count = %d", getMessageCount()); Timber.i("After clear size = %d", getSize()); } } public int getMessageCount() throws MessagingException { return database.execute(false, new DbCallback<Integer>() { @Override public Integer doDbWork(final SQLiteDatabase db) { Cursor cursor = null; try { cursor = db.rawQuery("SELECT COUNT(*) FROM messages", null); cursor.moveToFirst(); return cursor.getInt(0); // message count } finally { Utility.closeQuietly(cursor); } } }); } public int getFolderCount() throws MessagingException { return database.execute(false, new DbCallback<Integer>() { @Override public Integer doDbWork(final SQLiteDatabase db) { Cursor cursor = null; try { cursor = db.rawQuery("SELECT COUNT(*) FROM folders", null); cursor.moveToFirst(); return cursor.getInt(0); // folder count } finally { Utility.closeQuietly(cursor); } } }); } @Override public LocalFolder getFolder(String name) { return new LocalFolder(this, name); } // TODO this takes about 260-300ms, seems slow. @Override public List<LocalFolder> getPersonalNamespaces(boolean forceListAll) throws MessagingException { final List<LocalFolder> folders = new LinkedList<>(); try { database.execute(false, new DbCallback < List <? extends Folder >> () { @Override public List <? extends Folder > doDbWork(final SQLiteDatabase db) throws WrappedException { Cursor cursor = null; try { cursor = db.rawQuery("SELECT " + GET_FOLDER_COLS + " FROM folders " + "ORDER BY name ASC", null); while (cursor.moveToNext()) { if (cursor.isNull(FOLDER_ID_INDEX)) { continue; } String folderName = cursor.getString(FOLDER_NAME_INDEX); LocalFolder folder = new LocalFolder(LocalStore.this, folderName); folder.open(cursor); folders.add(folder); } return folders; } catch (MessagingException e) { throw new WrappedException(e); } finally { Utility.closeQuietly(cursor); } } }); } catch (WrappedException e) { throw(MessagingException) e.getCause(); } return folders; } @Override public void checkSettings() throws MessagingException { } public void delete() throws UnavailableStorageException { database.delete(); } public void recreate() throws UnavailableStorageException { database.recreate(); } private void deleteAllMessageDataFromDisk() throws MessagingException { markAllMessagePartsDataAsMissing(); deleteAllMessagePartsDataFromDisk(); } private void markAllMessagePartsDataAsMissing() throws MessagingException { database.execute(false, new DbCallback<Void>() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException { ContentValues cv = new ContentValues(); cv.put("data_location", DataLocation.MISSING); db.update("message_parts", cv, null, null); return null; } }); } private void deleteAllMessagePartsDataFromDisk() { final StorageManager storageManager = StorageManager.getInstance(context); File attachmentDirectory = storageManager.getAttachmentDirectory(uUid, database.getStorageProviderId()); File[] files = attachmentDirectory.listFiles(); if (files == null) { return; } for (File file : files) { if (file.exists() && !file.delete()) { file.deleteOnExit(); } } } public void resetVisibleLimits(int visibleLimit) throws MessagingException { final ContentValues cv = new ContentValues(); cv.put("visible_limit", Integer.toString(visibleLimit)); cv.put("more_messages", MoreMessages.UNKNOWN.getDatabaseName()); database.execute(false, new DbCallback<Void>() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException { db.update("folders", cv, null, null); return null; } }); } public List<PendingCommand> getPendingCommands() throws MessagingException { return database.execute(false, new DbCallback<List<PendingCommand>>() { @Override public List<PendingCommand> doDbWork(final SQLiteDatabase db) throws WrappedException { Cursor cursor = null; try { cursor = db.query("pending_commands", new String[] { "id", "command", "data" }, null, null, null, null, "id ASC"); List<PendingCommand> commands = new ArrayList<>(); while (cursor.moveToNext()) { long databaseId = cursor.getLong(0); String commandName = cursor.getString(1); String data = cursor.getString(2); PendingCommand command = pendingCommandSerializer.unserialize( databaseId, commandName, data); commands.add(command); } return commands; } finally { Utility.closeQuietly(cursor); } } }); } public void addPendingCommand(PendingCommand command) throws MessagingException { final ContentValues cv = new ContentValues(); cv.put("command", command.getCommandName()); cv.put("data", pendingCommandSerializer.serialize(command)); database.execute(false, new DbCallback<Void>() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException { db.insert("pending_commands", "command", cv); return null; } }); } public void removePendingCommand(final PendingCommand command) throws MessagingException { database.execute(false, new DbCallback<Void>() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException { db.delete("pending_commands", "id = ?", new String[] { Long.toString(command.databaseId) }); return null; } }); } public void removePendingCommands() throws MessagingException { database.execute(false, new DbCallback<Void>() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException { db.delete("pending_commands", null, null); return null; } }); } @Override public boolean isMoveCapable() { return true; } @Override public boolean isCopyCapable() { return true; } public List<LocalMessage> searchForMessages(MessageRetrievalListener<LocalMessage> retrievalListener, LocalSearch search) throws MessagingException { StringBuilder query = new StringBuilder(); List<String> queryArgs = new ArrayList<>(); SqlQueryBuilder.buildWhereClause(mAccount, search.getConditions(), query, queryArgs); // Avoid "ambiguous column name" error by prefixing "id" with the message table name String where = SqlQueryBuilder.addPrefixToSelection(new String[] { "id" }, "messages.", query.toString()); String[] selectionArgs = queryArgs.toArray(new String[queryArgs.size()]); String sqlQuery = "SELECT " + GET_MESSAGES_COLS + "FROM messages " + "LEFT JOIN threads ON (threads.message_id = messages.id) " + "LEFT JOIN message_parts ON (message_parts.id = messages.message_part_id) " + "LEFT JOIN folders ON (folders.id = messages.folder_id) WHERE " + "(empty = 0 AND deleted = 0)" + ((!TextUtils.isEmpty(where)) ? " AND (" + where + ")" : "") + " ORDER BY date DESC"; Timber.d("Query = %s", sqlQuery); return getMessages(retrievalListener, null, sqlQuery, selectionArgs); } /* * Given a query string, actually do the query for the messages and * call the MessageRetrievalListener for each one */ List<LocalMessage> getMessages( final MessageRetrievalListener<LocalMessage> listener, final LocalFolder folder, final String queryString, final String[] placeHolders ) throws MessagingException { final List<LocalMessage> messages = new ArrayList<>(); final int j = database.execute(false, new DbCallback<Integer>() { @Override public Integer doDbWork(final SQLiteDatabase db) throws WrappedException { Cursor cursor = null; int i = 0; try { cursor = db.rawQuery(queryString + " LIMIT 10", placeHolders); while (cursor.moveToNext()) { LocalMessage message = new LocalMessage(LocalStore.this, null, folder); message.populateFromGetMessageCursor(cursor); messages.add(message); if (listener != null) { listener.messageFinished(message, i, -1); } i++; } cursor.close(); cursor = db.rawQuery(queryString + " LIMIT -1 OFFSET 10", placeHolders); while (cursor.moveToNext()) { LocalMessage message = new LocalMessage(LocalStore.this, null, folder); message.populateFromGetMessageCursor(cursor); messages.add(message); if (listener != null) { listener.messageFinished(message, i, -1); } i++; } } catch (Exception e) { Timber.d(e, "Got an exception"); } finally { Utility.closeQuietly(cursor); } return i; } }); if (listener != null) { listener.messagesFinished(j); } return Collections.unmodifiableList(messages); } public List<LocalMessage> getMessagesInThread(final long rootId) throws MessagingException { String rootIdString = Long.toString(rootId); LocalSearch search = new LocalSearch(); search.and(SearchField.THREAD_ID, rootIdString, Attribute.EQUALS); return searchForMessages(null, search); } public AttachmentInfo getAttachmentInfo(final String attachmentId) throws MessagingException { return database.execute(false, new DbCallback<AttachmentInfo>() { @Override public AttachmentInfo doDbWork(final SQLiteDatabase db) throws WrappedException { Cursor cursor = db.query("message_parts", new String[] { "display_name", "decoded_body_size", "mime_type" }, "id = ?", new String[] { attachmentId }, null, null, null); try { if (!cursor.moveToFirst()) { return null; } String name = cursor.getString(0); long size = cursor.getLong(1); String mimeType = cursor.getString(2); final AttachmentInfo attachmentInfo = new AttachmentInfo(); attachmentInfo.name = name; attachmentInfo.size = size; attachmentInfo.type = mimeType; return attachmentInfo; } finally { cursor.close(); } } }); } @Nullable public OpenPgpDataSource getAttachmentDataSource(final String partId) throws MessagingException { return new OpenPgpDataSource() { @Override public void writeTo(OutputStream os) throws IOException { writeAttachmentDataToOutputStream(partId, os); } }; } private void writeAttachmentDataToOutputStream(final String partId, final OutputStream outputStream) throws IOException { try { database.execute(false, new DbCallback<Void>() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException, MessagingException { Cursor cursor = db.query("message_parts", GET_ATTACHMENT_COLS, "id = ?", new String[] { partId }, null, null, null); try { writeCursorPartsToOutputStream(db, cursor, outputStream); } catch (IOException e) { throw new WrappedException(e); } finally { Utility.closeQuietly(cursor); } return null; } }); } catch (MessagingException e) { throw new IOException("Got a MessagingException while writing attachment data!", e); } catch (WrappedException e) { throw (IOException) e.getCause(); } } private void writeCursorPartsToOutputStream(SQLiteDatabase db, Cursor cursor, OutputStream outputStream) throws IOException, MessagingException { while (cursor.moveToNext()) { String partId = cursor.getString(ATTACH_PART_ID_INDEX); int location = cursor.getInt(ATTACH_LOCATION_INDEX); if (location == DataLocation.IN_DATABASE || location == DataLocation.ON_DISK) { writeSimplePartToOutputStream(partId, cursor, outputStream); } else if (location == DataLocation.CHILD_PART_CONTAINS_DATA) { writeRawBodyToStream(cursor, db, outputStream); } } } private void writeRawBodyToStream(Cursor cursor, SQLiteDatabase db, OutputStream outputStream) throws IOException, MessagingException { long partId = cursor.getLong(ATTACH_PART_ID_INDEX); String rootPart = cursor.getString(ATTACH_ROOT_INDEX); LocalMessage message = loadLocalMessageByRootPartId(db, rootPart); if (message == null) { throw new MessagingException("Unable to find message for attachment!"); } Part part = findPartById(message, partId); if (part == null) { throw new MessagingException("Unable to find attachment part in associated message (db integrity error?)"); } Body body = part.getBody(); if (body == null) { throw new MessagingException("Attachment part isn't available!"); } body.writeTo(outputStream); } static Part findPartById(Part searchRoot, long partId) { if (searchRoot instanceof LocalMessage) { LocalMessage localMessage = (LocalMessage) searchRoot; if (localMessage.getMessagePartId() == partId) { return localMessage; } } Stack<Part> partStack = new Stack<>(); partStack.add(searchRoot); while (!partStack.empty()) { Part part = partStack.pop(); if (part instanceof LocalPart) { LocalPart localBodyPart = (LocalPart) part; if (localBodyPart.getId() == partId) { return part; } } Body body = part.getBody(); if (body instanceof Multipart) { Multipart innerMultipart = (Multipart) body; for (BodyPart innerPart : innerMultipart.getBodyParts()) { partStack.add(innerPart); } } if (body instanceof Part) { partStack.add((Part) body); } } return null; } private LocalMessage loadLocalMessageByRootPartId(SQLiteDatabase db, String rootPart) throws MessagingException { Cursor cursor = db.query("messages", new String[] { "id" }, "message_part_id = ?", new String[] { rootPart }, null, null, null); long messageId; try { if (!cursor.moveToFirst()) { return null; } messageId = cursor.getLong(0); } finally { Utility.closeQuietly(cursor); } return loadLocalMessageByMessageId(messageId); } @Nullable private LocalMessage loadLocalMessageByMessageId(long messageId) throws MessagingException { Map<String, List<String>> foldersAndUids = getFoldersAndUids(Collections.singletonList(messageId), false); if (foldersAndUids.isEmpty()) { return null; } Map.Entry<String,List<String>> entry = foldersAndUids.entrySet().iterator().next(); String folderName = entry.getKey(); String uid = entry.getValue().get(0); LocalFolder folder = getFolder(folderName); LocalMessage localMessage = folder.getMessage(uid); FetchProfile fp = new FetchProfile(); fp.add(Item.BODY); folder.fetch(Collections.singletonList(localMessage), fp, null); return localMessage; } private void writeSimplePartToOutputStream(String partId, Cursor cursor, OutputStream outputStream) throws IOException { int location = cursor.getInt(ATTACH_LOCATION_INDEX); InputStream inputStream = getRawAttachmentInputStream(partId, location, cursor); try { String encoding = cursor.getString(ATTACH_ENCODING_INDEX); inputStream = getDecodingInputStream(inputStream, encoding); IOUtils.copy(inputStream, outputStream); } finally { IOUtils.closeQuietly(inputStream); } } private InputStream getRawAttachmentInputStream(String partId, int location, Cursor cursor) throws FileNotFoundException { switch (location) { case DataLocation.IN_DATABASE: { byte[] data = cursor.getBlob(ATTACH_DATA_INDEX); return new ByteArrayInputStream(data); } case DataLocation.ON_DISK: { File file = getAttachmentFile(partId); return new FileInputStream(file); } default: throw new IllegalStateException("unhandled case"); } } InputStream getDecodingInputStream(final InputStream rawInputStream, @Nullable String encoding) { if (MimeUtil.ENC_BASE64.equals(encoding)) { return new Base64InputStream(rawInputStream) { @Override public void close() throws IOException { super.close(); rawInputStream.close(); } }; } if (MimeUtil.ENC_QUOTED_PRINTABLE.equals(encoding)) { return new QuotedPrintableInputStream(rawInputStream) { @Override public void close() throws IOException { super.close(); rawInputStream.close(); } }; } return rawInputStream; } File getAttachmentFile(String attachmentId) { final StorageManager storageManager = StorageManager.getInstance(context); final File attachmentDirectory = storageManager.getAttachmentDirectory(uUid, database.getStorageProviderId()); return new File(attachmentDirectory, attachmentId); } public static class AttachmentInfo { public String name; public long size; public String type; } public void createFolders(final List<LocalFolder> foldersToCreate, final int visibleLimit) throws MessagingException { database.execute(true, new DbCallback<Void>() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException { for (LocalFolder folder : foldersToCreate) { String name = folder.getName(); final LocalFolder.PreferencesHolder prefHolder = folder.new PreferencesHolder(); // When created, special folders should always be displayed // inbox should be integrated // and the inbox and drafts folders should be syncced by default if (mAccount.isSpecialFolder(name)) { prefHolder.inTopGroup = true; prefHolder.displayClass = LocalFolder.FolderClass.FIRST_CLASS; if (name.equalsIgnoreCase(mAccount.getInboxFolderName())) { prefHolder.integrate = true; prefHolder.notifyClass = LocalFolder.FolderClass.FIRST_CLASS; prefHolder.pushClass = LocalFolder.FolderClass.FIRST_CLASS; } else { prefHolder.pushClass = LocalFolder.FolderClass.INHERITED; } if (name.equalsIgnoreCase(mAccount.getInboxFolderName()) || name.equalsIgnoreCase(mAccount.getDraftsFolderName())) { prefHolder.syncClass = LocalFolder.FolderClass.FIRST_CLASS; } else { prefHolder.syncClass = LocalFolder.FolderClass.NO_CLASS; } } folder.refresh(name, prefHolder); // Recover settings from Preferences db.execSQL("INSERT INTO folders (name, visible_limit, top_group, display_class, poll_class, notify_class, push_class, integrate) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", new Object[] { name, visibleLimit, prefHolder.inTopGroup ? 1 : 0, prefHolder.displayClass.name(), prefHolder.syncClass.name(), prefHolder.notifyClass.name(), prefHolder.pushClass.name(), prefHolder.integrate ? 1 : 0, }); } return null; } }); } static String serializeFlags(Iterable<Flag> flags) { List<Flag> extraFlags = new ArrayList<>(); for (Flag flag : flags) { switch (flag) { case DELETED: case SEEN: case FLAGGED: case ANSWERED: case FORWARDED: { break; } default: { extraFlags.add(flag); } } } return Utility.combine(extraFlags, ',').toUpperCase(Locale.US); } // TODO: database should not be exposed! public LockableDatabase getDatabase() { return database; } public MessagePreviewCreator getMessagePreviewCreator() { return messagePreviewCreator; } public MessageFulltextCreator getMessageFulltextCreator() { return messageFulltextCreator; } public AttachmentCounter getAttachmentCounter() { return attachmentCounter; } void notifyChange() { Uri uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/" + uUid + "/messages"); mContentResolver.notifyChange(uri, null); } /** * Split database operations with a large set of arguments into multiple SQL statements. * * <p> * 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. * </p> * * @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<String> selectionArgs = new ArrayList<>(); int start = 0; while (start < selectionCallback.getListSize()) { final StringBuilder selection = new StringBuilder(); selection.append(" IN ("); int count = Math.min(selectionCallback.getListSize() - start, batchSize); for (int i = start, end = start + count; i < end; i++) { if (i > start) { selection.append(",?"); } else { selection.append("?"); } selectionArgs.add(selectionCallback.getListItem(i)); } selection.append(")"); try { database.execute(true, new DbCallback<Void>() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { selectionCallback.doDbWork(db, selection.toString(), selectionArgs.toArray(new String[selectionArgs.size()])); return null; } }); selectionCallback.postDbWork(); } catch (WrappedException e) { throw(MessagingException) e.getCause(); } selectionArgs.clear(); start += count; } } /** * Defines the behavior of {@link LocalStore#doBatchSetSelection(BatchSetSelection, int)}. */ public interface BatchSetSelection { /** * @return The size of the argument list. */ int getListSize(); /** * Get a specific item of the argument list. * * @param index * The index of the item. * * @return Item at position {@code i} of the argument list. */ String getListItem(int index); /** * Execute the SQL statement. * * @param db * Use this {@link SQLiteDatabase} instance for your SQL statement. * @param selectionSet * A partial selection string containing place holders for the argument list, e.g. * {@code " IN (?,?,?)"} (starts with a space). * @param selectionArgs * The current subset of the argument list. * @throws UnavailableStorageException */ void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) throws UnavailableStorageException; /** * This will be executed after each invocation of * {@link #doDbWork(SQLiteDatabase, String, String[])} (after the transaction has been * committed). */ void postDbWork(); } /** * Change the state of a flag for a list of messages. * * <p> * 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<Long> messageIds, final Flag flag, final boolean newState) throws MessagingException { final ContentValues cv = new ContentValues(); cv.put(getColumnNameForFlag(flag), newState); doBatchSetSelection(new BatchSetSelection() { @Override public int getListSize() { return messageIds.size(); } @Override public String getListItem(int index) { return Long.toString(messageIds.get(index)); } @Override public void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) throws UnavailableStorageException { db.update("messages", cv, "empty = 0 AND id" + selectionSet, selectionArgs); } @Override public void postDbWork() { notifyChange(); } }, FLAG_UPDATE_BATCH_SIZE); } /** * Change the state of a flag for a list of threads. * * <p> * 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<Long> threadRootIds, Flag flag, final boolean newState) throws MessagingException { final String flagColumn = getColumnNameForFlag(flag); doBatchSetSelection(new BatchSetSelection() { @Override public int getListSize() { return threadRootIds.size(); } @Override public String getListItem(int index) { return Long.toString(threadRootIds.get(index)); } @Override public void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) throws UnavailableStorageException { db.execSQL("UPDATE messages SET " + flagColumn + " = " + ((newState) ? "1" : "0") + " WHERE id IN (" + "SELECT m.id FROM threads t " + "LEFT JOIN messages m ON (t.message_id = m.id) " + "WHERE m.empty = 0 AND m.deleted = 0 " + "AND t.root" + selectionSet + ")", selectionArgs); } @Override public void postDbWork() { notifyChange(); } }, THREAD_FLAG_UPDATE_BATCH_SIZE); } /** * Get folder name and UID for the supplied messages. * * @param messageIds * A list of primary keys in the "messages" table. * @param threadedList * If this is {@code true}, {@code messageIds} contains the thread IDs of the messages * at the root of a thread. In that case return UIDs for all messages in these threads. * If this is {@code false} only the UIDs for messages in {@code messageIds} are * returned. * * @return The list of UIDs for the messages grouped by folder name. * * @throws MessagingException */ public Map<String, List<String>> getFoldersAndUids(final List<Long> messageIds, final boolean threadedList) throws MessagingException { final Map<String, List<String>> folderMap = new HashMap<>(); doBatchSetSelection(new BatchSetSelection() { @Override public int getListSize() { return messageIds.size(); } @Override public String getListItem(int index) { return Long.toString(messageIds.get(index)); } @Override public void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) throws UnavailableStorageException { if (threadedList) { String sql = "SELECT m.uid, f.name " + "FROM threads t " + "LEFT JOIN messages m ON (t.message_id = m.id) " + "LEFT JOIN folders f ON (m.folder_id = f.id) " + "WHERE m.empty = 0 AND m.deleted = 0 " + "AND t.root" + selectionSet; getDataFromCursor(db.rawQuery(sql, selectionArgs)); } else { String sql = "SELECT m.uid, f.name " + "FROM messages m " + "LEFT JOIN folders f ON (m.folder_id = f.id) " + "WHERE m.empty = 0 AND m.id" + selectionSet; getDataFromCursor(db.rawQuery(sql, selectionArgs)); } } private void getDataFromCursor(Cursor cursor) { try { while (cursor.moveToNext()) { String uid = cursor.getString(0); String folderName = cursor.getString(1); List<String> uidList = folderMap.get(folderName); if (uidList == null) { uidList = new ArrayList<>(); folderMap.put(folderName, uidList); } uidList.add(uid); } } finally { cursor.close(); } } @Override public void postDbWork() { notifyChange(); } }, UID_CHECK_BATCH_SIZE); return folderMap; } }