diff --git a/src/com/fsck/k9/activity/MessageList.java b/src/com/fsck/k9/activity/MessageList.java index 31eda7daf..47cbc3cd4 100644 --- a/src/com/fsck/k9/activity/MessageList.java +++ b/src/com/fsck/k9/activity/MessageList.java @@ -371,16 +371,7 @@ public class MessageList extends K9FragmentActivity implements MessageListFragme int itemId = item.getItemId(); switch (itemId) { case android.R.id.home: { - FragmentManager fragmentManager = getSupportFragmentManager(); - if (fragmentManager.getBackStackEntryCount() > 0) { - fragmentManager.popBackStack(); - } else if (mMessageListFragment.isManualSearch()) { - onBackPressed(); - } else if (!mSingleFolderMode) { - onAccounts(); - } else { - onShowFolderList(); - } + goBack(); return true; } case R.id.compose: { @@ -719,8 +710,7 @@ public class MessageList extends K9FragmentActivity implements MessageListFragme public void showThread(Account account, String folderName, long threadRootId) { LocalSearch tmpSearch = new LocalSearch(); tmpSearch.addAccountUuid(account.getUuid()); - tmpSearch.and(Searchfield.THREAD_ROOT, String.valueOf(threadRootId), Attribute.EQUALS); - tmpSearch.or(new SearchCondition(Searchfield.ID, Attribute.EQUALS, String.valueOf(threadRootId))); + tmpSearch.and(Searchfield.THREAD_ID, String.valueOf(threadRootId), Attribute.EQUALS); MessageListFragment fragment = MessageListFragment.newInstance(tmpSearch, true, false); addMessageListFragment(fragment, true); @@ -731,4 +721,18 @@ public class MessageList extends K9FragmentActivity implements MessageListFragme // Remove action button for remote search configureMenu(mMenu); } + + @Override + public void goBack() { + FragmentManager fragmentManager = getSupportFragmentManager(); + if (fragmentManager.getBackStackEntryCount() > 0) { + fragmentManager.popBackStack(); + } else if (mMessageListFragment.isManualSearch()) { + onBackPressed(); + } else if (!mSingleFolderMode) { + onAccounts(); + } else { + onShowFolderList(); + } + } } diff --git a/src/com/fsck/k9/controller/MessagingController.java b/src/com/fsck/k9/controller/MessagingController.java index 6ff3550b3..50c0e868f 100644 --- a/src/com/fsck/k9/controller/MessagingController.java +++ b/src/com/fsck/k9/controller/MessagingController.java @@ -2680,17 +2680,28 @@ public class MessagingController implements Runnable { } public void setFlag(final Account account, final List messageIds, final Flag flag, - final boolean newState, final boolean threadedList) { + final boolean newState) { threadPool.execute(new Runnable() { @Override public void run() { - setFlagSynchronous(account, messageIds, flag, newState, threadedList); + setFlagSynchronous(account, messageIds, flag, newState, false); } }); } - private void setFlagSynchronous(final Account account, final List messageIds, + public void setFlagForThreads(final Account account, final List threadRootIds, + final Flag flag, final boolean newState) { + + threadPool.execute(new Runnable() { + @Override + public void run() { + setFlagSynchronous(account, threadRootIds, flag, newState, true); + } + }); + } + + private void setFlagSynchronous(final Account account, final List ids, final Flag flag, final boolean newState, final boolean threadedList) { LocalStore localStore; @@ -2704,7 +2715,11 @@ public class MessagingController implements Runnable { // Update affected messages in the database. This should be as fast as possible so the UI // can be updated with the new state. try { - localStore.setFlag(messageIds, flag, newState, threadedList); + if (threadedList) { + localStore.setFlagForThreads(ids, flag, newState); + } else { + localStore.setFlag(ids, flag, newState); + } } catch (MessagingException e) { Log.e(K9.LOG_TAG, "Couldn't set flags in local database", e); } @@ -2712,7 +2727,7 @@ public class MessagingController implements Runnable { // Read folder name and UID of messages from the database Map> folderMap; try { - folderMap = localStore.getFoldersAndUids(messageIds, threadedList); + folderMap = localStore.getFoldersAndUids(ids, threadedList); } catch (MessagingException e) { Log.e(K9.LOG_TAG, "Couldn't get folder name and UID of messages", e); return; @@ -3905,8 +3920,9 @@ public class MessagingController implements Runnable { List messagesInThreads = new ArrayList(); for (Message message : messages) { - long rootId = ((LocalMessage) message).getRootId(); - long threadId = (rootId == -1) ? message.getId() : rootId; + LocalMessage localMessage = (LocalMessage) message; + long rootId = localMessage.getRootId(); + long threadId = (rootId == -1) ? localMessage.getThreadId() : rootId; Message[] messagesInThread = localStore.getMessagesInThread(threadId); Collections.addAll(messagesInThreads, messagesInThread); diff --git a/src/com/fsck/k9/fragment/MessageListFragment.java b/src/com/fsck/k9/fragment/MessageListFragment.java index 578eb75c4..98f2d3862 100644 --- a/src/com/fsck/k9/fragment/MessageListFragment.java +++ b/src/com/fsck/k9/fragment/MessageListFragment.java @@ -91,8 +91,12 @@ import com.fsck.k9.mail.store.LocalStore.LocalFolder; import com.fsck.k9.provider.EmailProvider; import com.fsck.k9.provider.EmailProvider.MessageColumns; import com.fsck.k9.provider.EmailProvider.SpecialColumns; +import com.fsck.k9.provider.EmailProvider.ThreadColumns; +import com.fsck.k9.search.ConditionsTreeNode; import com.fsck.k9.search.LocalSearch; import com.fsck.k9.search.SearchSpecification; +import com.fsck.k9.search.SearchSpecification.SearchCondition; +import com.fsck.k9.search.SearchSpecification.Searchfield; import com.fsck.k9.search.SqlQueryBuilder; import com.handmark.pulltorefresh.library.PullToRefreshBase; import com.handmark.pulltorefresh.library.PullToRefreshListView; @@ -117,11 +121,11 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick MessageColumns.ATTACHMENT_COUNT, MessageColumns.FOLDER_ID, MessageColumns.PREVIEW, - MessageColumns.THREAD_ROOT, + ThreadColumns.ROOT, SpecialColumns.ACCOUNT_UUID, SpecialColumns.FOLDER_NAME, - MessageColumns.THREAD_COUNT, + SpecialColumns.THREAD_COUNT, }; private static final int ID_COLUMN = 0; @@ -425,6 +429,7 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick private static final int ACTION_REFRESH_TITLE = 2; private static final int ACTION_PROGRESS = 3; private static final int ACTION_REMOTE_SEARCH_FINISHED = 4; + private static final int ACTION_GO_BACK = 5; public void folderLoading(String folder, boolean loading) { @@ -458,6 +463,11 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick }); } + public void goBack() { + android.os.Message msg = android.os.Message.obtain(this, ACTION_GO_BACK); + sendMessage(msg); + } + @Override public void handleMessage(android.os.Message msg) { // The following messages don't need an attached activity. @@ -490,6 +500,10 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick MessageListFragment.this.progress(progress); break; } + case ACTION_GO_BACK: { + mFragmentListener.goBack(); + break; + } } } } @@ -1195,8 +1209,9 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick mActiveMessages = null; // don't need it any more - final Account account = messages.get(0).getFolder().getAccount(); - account.setLastSelectedFolderName(destFolderName); + // We currently only support copy/move in 'single account mode', so it's okay to + // use mAccount. + mAccount.setLastSelectedFolderName(destFolderName); switch (requestCode) { case ACTIVITY_CHOOSE_FOLDER_MOVE: @@ -2052,10 +2067,16 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick Cursor cursor = (Cursor) mAdapter.getItem(adapterPosition); Account account = mPreferences.getAccount(cursor.getString(ACCOUNT_UUID_COLUMN)); - long id = cursor.getLong(ID_COLUMN); - mController.setFlag(account, Collections.singletonList(Long.valueOf(id)), flag, newState, - mThreadedList); + if (mThreadedList && cursor.getInt(THREAD_COUNT_COLUMN) > 1) { + long threadRootId = cursor.getLong(THREAD_ROOT_COLUMN); + mController.setFlagForThreads(account, + Collections.singletonList(Long.valueOf(threadRootId)), flag, newState); + } else { + long id = cursor.getLong(ID_COLUMN); + mController.setFlag(account, Collections.singletonList(Long.valueOf(id)), flag, + newState); + } computeBatchDirection(); } @@ -2065,7 +2086,8 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick return; } - Map> accountMapping = new HashMap>(); + Map> messageMap = new HashMap>(); + Map> threadMap = new HashMap>(); Set accounts = new HashSet(); for (int position = 0, end = mAdapter.getCount(); position < end; position++) { @@ -2075,29 +2097,39 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick if (mSelected.contains(uniqueId)) { String uuid = cursor.getString(ACCOUNT_UUID_COLUMN); Account account = mPreferences.getAccount(uuid); - accounts.add(account); - List messageIdList = accountMapping.get(account); - if (messageIdList == null) { - messageIdList = new ArrayList(); - accountMapping.put(account, messageIdList); - } - long selectionId; - if (mThreadedList) { - selectionId = (cursor.isNull(THREAD_ROOT_COLUMN)) ? - cursor.getLong(ID_COLUMN) : cursor.getLong(THREAD_ROOT_COLUMN); + if (mThreadedList && cursor.getInt(THREAD_COUNT_COLUMN) > 1) { + List threadRootIdList = threadMap.get(account); + if (threadRootIdList == null) { + threadRootIdList = new ArrayList(); + threadMap.put(account, threadRootIdList); + } + + threadRootIdList.add(cursor.getLong(THREAD_ROOT_COLUMN)); } else { - selectionId = cursor.getLong(ID_COLUMN); - } + List messageIdList = messageMap.get(account); + if (messageIdList == null) { + messageIdList = new ArrayList(); + messageMap.put(account, messageIdList); + } - messageIdList.add(selectionId); + messageIdList.add(cursor.getLong(ID_COLUMN)); + } } } for (Account account : accounts) { - List messageIds = accountMapping.get(account); - mController.setFlag(account, messageIds, flag, newState, mThreadedList); + List messageIds = messageMap.get(account); + List threadRootIds = threadMap.get(account); + + if (messageIds != null) { + mController.setFlag(account, messageIds, flag, newState); + } + + if (threadRootIds != null) { + mController.setFlagForThreads(account, threadRootIds, flag, newState); + } } computeBatchDirection(); @@ -2118,10 +2150,16 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick return; } - final Folder folder = (messages.size() == 1) ? - messages.get(0).getFolder() : mCurrentFolder.folder; + final Folder folder; + if (mIsThreadDisplay) { + folder = messages.get(0).getFolder(); + } else if (mSingleFolderMode) { + folder = mCurrentFolder.folder; + } else { + folder = null; + } - displayFolderChoice(ACTIVITY_CHOOSE_FOLDER_MOVE, folder, messages); + displayFolderChoice(ACTIVITY_CHOOSE_FOLDER_MOVE, mAccount, folder, messages); } private void onCopy(Message message) { @@ -2139,10 +2177,16 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick return; } - final Folder folder = (messages.size() == 1) ? - messages.get(0).getFolder() : mCurrentFolder.folder; + final Folder folder; + if (mIsThreadDisplay) { + folder = messages.get(0).getFolder(); + } else if (mSingleFolderMode) { + folder = mCurrentFolder.folder; + } else { + folder = null; + } - displayFolderChoice(ACTIVITY_CHOOSE_FOLDER_COPY, folder, messages); + displayFolderChoice(ACTIVITY_CHOOSE_FOLDER_COPY, mAccount, folder, messages); } /** @@ -2159,11 +2203,19 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick * * @see #startActivityForResult(Intent, int) */ - private void displayFolderChoice(final int requestCode, final Folder folder, final List messages) { - final Intent intent = new Intent(getActivity(), ChooseFolder.class); - intent.putExtra(ChooseFolder.EXTRA_ACCOUNT, folder.getAccount().getUuid()); - intent.putExtra(ChooseFolder.EXTRA_CUR_FOLDER, folder.getName()); - intent.putExtra(ChooseFolder.EXTRA_SEL_FOLDER, folder.getAccount().getLastSelectedFolderName()); + private void displayFolderChoice(int requestCode, Account account, Folder folder, + List messages) { + + Intent intent = new Intent(getActivity(), ChooseFolder.class); + intent.putExtra(ChooseFolder.EXTRA_ACCOUNT, account.getUuid()); + intent.putExtra(ChooseFolder.EXTRA_SEL_FOLDER, account.getLastSelectedFolderName()); + + if (folder == null) { + intent.putExtra(ChooseFolder.EXTRA_SHOW_CURRENT, "yes"); + } else { + intent.putExtra(ChooseFolder.EXTRA_CUR_FOLDER, folder.getName()); + } + // remember the selected messages for #onActivityResult mActiveMessages = messages; startActivityForResult(intent, requestCode); @@ -2318,37 +2370,14 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick private void copyOrMove(List messages, final String destination, final FolderOperation operation) { - if (K9.FOLDER_NONE.equalsIgnoreCase(destination)) { + if (K9.FOLDER_NONE.equalsIgnoreCase(destination) || !mSingleAccountMode) { return; } - boolean first = true; - Account account = null; - String folderName = null; - - List outMessages = new ArrayList(); + Account account = mAccount; + Map> folderMap = new HashMap>(); for (Message message : messages) { - if (first) { - first = false; - - folderName = message.getFolder().getName(); - account = message.getFolder().getAccount(); - - if ((operation == FolderOperation.MOVE && !mController.isMoveCapable(account)) || - (operation == FolderOperation.COPY && - !mController.isCopyCapable(account))) { - - // Account is not copy/move capable - return; - } - } else if (!message.getFolder().getAccount().equals(account) || - !message.getFolder().getName().equals(folderName)) { - - // Make sure all messages come from the same account/folder - return; - } - if ((operation == FolderOperation.MOVE && !mController.isMoveCapable(message)) || (operation == FolderOperation.COPY && !mController.isCopyCapable(message))) { @@ -2361,20 +2390,36 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick return; } + String folderName = message.getFolder().getName(); + if (folderName.equals(destination)) { + // Skip messages already in the destination folder + continue; + } + + List outMessages = folderMap.get(folderName); + if (outMessages == null) { + outMessages = new ArrayList(); + folderMap.put(folderName, outMessages); + } + outMessages.add(message); } - if (operation == FolderOperation.MOVE) { - if (mThreadedList) { - mController.moveMessagesInThread(account, folderName, outMessages, destination); + for (String folderName : folderMap.keySet()) { + List outMessages = folderMap.get(folderName); + + if (operation == FolderOperation.MOVE) { + if (mThreadedList) { + mController.moveMessagesInThread(account, folderName, outMessages, destination); + } else { + mController.moveMessages(account, folderName, outMessages, destination, null); + } } else { - mController.moveMessages(account, folderName, outMessages, destination, null); - } - } else { - if (mThreadedList) { - mController.copyMessagesInThread(account, folderName, outMessages, destination); - } else { - mController.copyMessages(account, folderName, outMessages, destination, null); + if (mThreadedList) { + mController.copyMessagesInThread(account, folderName, outMessages, destination); + } else { + mController.copyMessages(account, folderName, outMessages, destination, null); + } } } } @@ -2713,6 +2758,7 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick void onCompose(Account account); boolean startSearch(Account account, String folderName); void remoteSearchStarted(); + void goBack(); } public void onReverseSort() { @@ -2888,19 +2934,30 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick String accountUuid = mAccountUuids[id]; Account account = mPreferences.getAccount(accountUuid); + String threadId = getThreadId(mSearch); + Uri uri; String[] projection; - if (mThreadedList) { + boolean needConditions; + if (threadId != null) { + uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/" + accountUuid + "/thread/" + threadId); + projection = PROJECTION; + needConditions = false; + } else if (mThreadedList) { uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/" + accountUuid + "/messages/threaded"); projection = THREADED_PROJECTION; + needConditions = true; } else { uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/" + accountUuid + "/messages"); projection = PROJECTION; + needConditions = true; } StringBuilder query = new StringBuilder(); List queryArgs = new ArrayList(); - SqlQueryBuilder.buildWhereClause(account, mSearch.getConditions(), query, queryArgs); + if (needConditions) { + SqlQueryBuilder.buildWhereClause(account, mSearch.getConditions(), query, queryArgs); + } String selection = query.toString(); String[] selectionArgs = queryArgs.toArray(new String[0]); @@ -2911,6 +2968,17 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick sortOrder); } + private String getThreadId(LocalSearch search) { + for (ConditionsTreeNode node : search.getLeafSet()) { + SearchCondition condition = node.mCondition; + if (condition.field == Searchfield.THREAD_ID) { + return condition.value; + } + } + + return null; + } + private String buildSortOrder() { String sortColumn = MessageColumns.ID; switch (mSortType) { @@ -2960,6 +3028,11 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick @Override public void onLoadFinished(Loader loader, Cursor data) { + if (mIsThreadDisplay && data.getCount() == 0) { + mHandler.goBack(); + return; + } + // Remove the "Loading..." view mPullToRefreshView.setEmptyView(null); diff --git a/src/com/fsck/k9/mail/store/LocalStore.java b/src/com/fsck/k9/mail/store/LocalStore.java index 4db8edd25..91d115aec 100644 --- a/src/com/fsck/k9/mail/store/LocalStore.java +++ b/src/com/fsck/k9/mail/store/LocalStore.java @@ -74,7 +74,6 @@ import com.fsck.k9.provider.AttachmentProvider; import com.fsck.k9.provider.EmailProvider; import com.fsck.k9.search.LocalSearch; import com.fsck.k9.search.SearchSpecification.Attribute; -import com.fsck.k9.search.SearchSpecification.SearchCondition; import com.fsck.k9.search.SearchSpecification.Searchfield; import com.fsck.k9.search.SqlQueryBuilder; @@ -96,8 +95,10 @@ public class LocalStore extends Store implements Serializable { * 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, message_id, folder_id, preview, thread_root, thread_parent, deleted, read, flagged, answered, forwarded "; + "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"; @@ -117,7 +118,14 @@ public class LocalStore extends Store implements Serializable { */ private static final int FLAG_UPDATE_BATCH_SIZE = 500; - public static final int DB_VERSION = 46; + /** + * Number of threads to perform flag updates on at once. + * + * @see #setFlagForThreads(List, Flag, boolean) + */ + private static final int THREAD_FLAG_UPDATE_BATCH_SIZE = 400; + + public static final int DB_VERSION = 47; protected String uUid = null; @@ -204,8 +212,6 @@ public class LocalStore extends Store implements Serializable { "message_id TEXT, " + "preview TEXT, " + "mime_type TEXT, "+ - "thread_root INTEGER, " + - "thread_parent INTEGER, " + "normalized_subject_hash INTEGER, " + "empty INTEGER, " + "read INTEGER default 0, " + @@ -226,18 +232,30 @@ public class LocalStore extends Store implements Serializable { 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)"); - 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 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," @@ -539,6 +557,76 @@ public class LocalStore extends Store implements Serializable { db.execSQL("CREATE INDEX IF NOT EXISTS msg_read ON messages (read)"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_flagged ON messages (flagged)"); } + + if (db.getVersion() < 47) { + // Create new 'threads' table + db.execSQL("DROP TABLE IF EXISTS threads"); + db.execSQL("CREATE TABLE threads (" + + "id INTEGER PRIMARY KEY, " + + "message_id INTEGER, " + + "root INTEGER, " + + "parent INTEGER" + + ")"); + + // Create indices for new table + 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)"); + + // Create entries for all messages in 'threads' table + db.execSQL("INSERT INTO threads (message_id) SELECT id FROM messages"); + + // Copy thread structure from 'messages' table to 'threads' + Cursor cursor = db.query("messages", + new String[] { "id", "thread_root", "thread_parent" }, + null, null, null, null, null); + try { + ContentValues cv = new ContentValues(); + while (cursor.moveToNext()) { + cv.clear(); + long messageId = cursor.getLong(0); + + if (!cursor.isNull(1)) { + long threadRootMessageId = cursor.getLong(1); + db.execSQL("UPDATE threads SET root = (SELECT t.id FROM " + + "threads t WHERE t.message_id = ?) " + + "WHERE message_id = ?", + new String[] { + Long.toString(threadRootMessageId), + Long.toString(messageId) + }); + } + + if (!cursor.isNull(2)) { + long threadParentMessageId = cursor.getLong(2); + db.execSQL("UPDATE threads SET parent = (SELECT t.id FROM " + + "threads t WHERE t.message_id = ?) " + + "WHERE message_id = ?", + new String[] { + Long.toString(threadParentMessageId), + Long.toString(messageId) + }); + } + } + } finally { + cursor.close(); + } + + // Remove indices for old thread-related columns in 'messages' table + db.execSQL("DROP INDEX IF EXISTS msg_thread_root"); + db.execSQL("DROP INDEX IF EXISTS msg_thread_parent"); + + // Clear out old thread-related columns in 'messages' + ContentValues cv = new ContentValues(); + cv.putNull("thread_root"); + cv.putNull("thread_parent"); + db.update("messages", cv, null, null); + } } } catch (SQLiteException e) { Log.e(K9.LOG_TAG, "Exception while upgrading database. Resetting the DB to v0"); @@ -671,8 +759,21 @@ public class LocalStore extends Store implements Serializable { database.execute(false, new DbCallback() { @Override public Void doDbWork(final SQLiteDatabase db) { - db.execSQL("DELETE FROM messages WHERE deleted = 0 and uid not like 'Local%'"); - db.execSQL("update folders set flagged_count = 0, unread_count = 0"); + // Delete entries from 'threads' table + db.execSQL("DELETE FROM threads WHERE message_id IN " + + "(SELECT id FROM messages WHERE deleted = 0 AND uid NOT LIKE 'Local%')"); + + // Set 'root' and 'parent' of remaining entries in 'thread' table to 'NULL' to make + // sure the thread structure is in a valid state (this may destroy existing valid + // thread trees, but is much faster than adjusting the tree by removing messages + // one by one). + ContentValues cv = new ContentValues(); + cv.putNull("root"); + cv.putNull("parent"); + db.update("threads", cv, null, null); + + // Delete entries from 'messages' table + db.execSQL("DELETE FROM messages WHERE deleted = 0 AND uid NOT LIKE 'Local%'"); return null; } }); @@ -979,6 +1080,7 @@ public class LocalStore extends Store implements Serializable { String[] selectionArgs = queryArgs.toArray(EMPTY_STRING_ARRAY); String sqlQuery = "SELECT " + GET_MESSAGES_COLS + "FROM messages " + + "LEFT JOIN threads ON (threads.message_id = messages.id) " + "LEFT JOIN folders ON (folders.id = messages.folder_id) WHERE " + "((empty IS NULL OR empty != 1) AND deleted = 0)" + ((!StringUtils.isNullOrEmpty(where)) ? " AND (" + where + ")" : "") + @@ -1052,8 +1154,7 @@ public class LocalStore extends Store implements Serializable { String rootIdString = Long.toString(rootId); LocalSearch search = new LocalSearch(); - search.and(Searchfield.THREAD_ROOT, rootIdString, Attribute.EQUALS); - search.or(new SearchCondition(Searchfield.ID, Attribute.EQUALS, rootIdString)); + search.and(Searchfield.THREAD_ID, rootIdString, Attribute.EQUALS); return searchForMessages(null, search); } @@ -1947,9 +2048,11 @@ public class LocalStore extends Store implements Serializable { try { cursor = db.rawQuery( - "SELECT " - + GET_MESSAGES_COLS - + "FROM messages WHERE uid = ? AND folder_id = ?", + "SELECT " + + GET_MESSAGES_COLS + + "FROM messages " + + "LEFT JOIN threads ON (threads.message_id = messages.id) " + + "WHERE uid = ? AND folder_id = ?", new String[] { message.getUid(), Long.toString(mFolderId) }); @@ -1987,13 +2090,13 @@ public class LocalStore extends Store implements Serializable { return LocalStore.this.getMessages( listener, LocalFolder.this, - "SELECT " + GET_MESSAGES_COLS - + "FROM messages WHERE (empty IS NULL OR empty != 1) AND " - + (includeDeleted ? "" : "deleted = 0 AND ") - + " folder_id = ? ORDER BY date DESC" - , new String[] { - Long.toString(mFolderId) - } + "SELECT " + GET_MESSAGES_COLS + + "FROM messages " + + "LEFT JOIN threads ON (threads.message_id = messages.id) " + + "WHERE (empty IS NULL OR empty != 1) AND " + + (includeDeleted ? "" : "deleted = 0 AND ") + + "folder_id = ? ORDER BY date DESC", + new String[] { Long.toString(mFolderId) } ); } catch (MessagingException e) { throw new WrappedException(e); @@ -2065,36 +2168,34 @@ public class LocalStore extends Store implements Serializable { ThreadInfo threadInfo = lDestFolder.doMessageThreading(db, message); /* - * "Move" the message into the new folder and thread structure + * "Move" the message into the new folder */ - long id = lMessage.getId(); - String[] idArg = new String[] { Long.toString(id) }; + long msgId = lMessage.getId(); + String[] idArg = new String[] { Long.toString(msgId) }; ContentValues cv = new ContentValues(); cv.put("folder_id", lDestFolder.getId()); cv.put("uid", newUid); - if (threadInfo.rootId != -1) { - cv.put("thread_root", threadInfo.rootId); - } - - if (threadInfo.parentId != -1) { - cv.put("thread_parent", threadInfo.parentId); - } - db.update("messages", cv, "id = ?", idArg); - if (threadInfo.id != -1) { - String[] oldIdArg = - new String[] { Long.toString(threadInfo.id) }; + // Create/update entry in 'threads' table for the message in the + // target folder + cv.clear(); + cv.put("message_id", msgId); + if (threadInfo.threadId == -1) { + if (threadInfo.rootId != -1) { + cv.put("root", threadInfo.rootId); + } - cv.clear(); - cv.put("thread_root", id); - db.update("messages", cv, "thread_root = ?", oldIdArg); + if (threadInfo.parentId != -1) { + cv.put("parent", threadInfo.parentId); + } - cv.clear(); - cv.put("thread_parent", id); - db.update("messages", cv, "thread_parent = ?", oldIdArg); + db.insert("threads", null, cv); + } else { + db.update("threads", cv, "id = ?", + new String[] { Long.toString(threadInfo.threadId) }); } /* @@ -2112,29 +2213,19 @@ public class LocalStore extends Store implements Serializable { cv.put("read", 1); cv.put("deleted", 1); cv.put("folder_id", mFolderId); + cv.put("empty", 0); String messageId = message.getMessageId(); if (messageId != null) { cv.put("message_id", messageId); - cv.put("empty", 1); - - long rootId = lMessage.getRootId(); - if (rootId != -1) { - cv.put("thread_root", rootId); - } - - long parentId = lMessage.getParentId(); - if (parentId != -1) { - cv.put("thread_parent", parentId); - } } final long newId; - if (threadInfo.id != -1) { + if (threadInfo.msgId != -1) { // There already existed an empty message in the target folder. // Let's use it as placeholder. - newId = threadInfo.id; + newId = threadInfo.msgId; db.update("messages", cv, "id = ?", new String[] { Long.toString(newId) }); @@ -2143,27 +2234,14 @@ public class LocalStore extends Store implements Serializable { } /* - * Replace all "thread links" to the original message with links to - * the placeholder message. + * Update old entry in 'threads' table to point to the newly + * created placeholder. */ - String[] whereArgs = new String[] { - Long.toString(mFolderId), - Long.toString(id) }; - - // Note: If there was an empty message in the target folder we - // already reconnected some messages to point to 'id'. We don't - // want to change those links again, so we limit the update - // statements below to the source folder. cv.clear(); - cv.put("thread_root", newId); - db.update("messages", cv, "folder_id = ? AND thread_root = ?", - whereArgs); - - cv.clear(); - cv.put("thread_parent", newId); - db.update("messages", cv, "folder_id = ? AND thread_parent = ?", - whereArgs); + cv.put("message_id", newId); + db.update("threads", cv, "id = ?", + new String[] { Long.toString(lMessage.getThreadId()) }); } } catch (MessagingException e) { throw new WrappedException(e); @@ -2285,21 +2363,23 @@ public class LocalStore extends Store implements Serializable { } private ThreadInfo getThreadInfo(SQLiteDatabase db, String messageId) { - Cursor cursor = db.query("messages", - new String[] { "id", "thread_root", "thread_parent" }, - "folder_id=? AND message_id=?", - new String[] { Long.toString(mFolderId), messageId }, - null, null, null); + String sql = "SELECT t.id, t.message_id, t.root, t.parent " + + "FROM messages m " + + "LEFT JOIN threads t ON (t.message_id = m.id) " + + "WHERE m.folder_id = ? AND m.message_id = ?"; + String[] selectionArgs = { Long.toString(mFolderId), messageId }; + Cursor cursor = db.rawQuery(sql, selectionArgs); if (cursor != null) { try { if (cursor.getCount() == 1) { cursor.moveToFirst(); - long id = cursor.getLong(0); - long rootId = (cursor.isNull(1)) ? -1 : cursor.getLong(1); - long parentId = (cursor.isNull(2)) ? -1 : cursor.getLong(2); + long threadId = cursor.getLong(0); + long msgId = cursor.getLong(1); + long rootId = (cursor.isNull(2)) ? -1 : cursor.getLong(2); + long parentId = (cursor.isNull(3)) ? -1 : cursor.getLong(3); - return new ThreadInfo(id, messageId, rootId, parentId); + return new ThreadInfo(threadId, msgId, messageId, rootId, parentId); } } finally { cursor.close(); @@ -2374,7 +2454,7 @@ public class LocalStore extends Store implements Serializable { if (oldMessageId == -1) { // This is a new message. Do the message threading. ThreadInfo threadInfo = doMessageThreading(db, message); - oldMessageId = threadInfo.id; + oldMessageId = threadInfo.msgId; rootId = threadInfo.rootId; parentId = threadInfo.parentId; } @@ -2437,25 +2517,32 @@ public class LocalStore extends Store implements Serializable { cv.put("message_id", messageId); } - if (rootId != -1) { - cv.put("thread_root", rootId); - } - if (parentId != -1) { - cv.put("thread_parent", parentId); - } - - long messageUid; + long msgId; if (oldMessageId == -1) { - messageUid = db.insert("messages", "uid", cv); + msgId = db.insert("messages", "uid", cv); + + // Create entry in 'threads' table + cv.clear(); + cv.put("message_id", msgId); + + if (rootId != -1) { + cv.put("root", rootId); + } + if (parentId != -1) { + cv.put("parent", parentId); + } + + db.insert("threads", null, cv); } else { db.update("messages", cv, "id = ?", new String[] { Long.toString(oldMessageId) }); - messageUid = oldMessageId; + msgId = oldMessageId; } + for (Part attachment : attachments) { - saveAttachment(messageUid, attachment, copy); + saveAttachment(msgId, attachment, copy); } - saveHeaders(messageUid, (MimeMessage)message); + saveHeaders(msgId, (MimeMessage)message); } catch (Exception e) { throw new MessagingException("Error appending message", e); } @@ -2852,47 +2939,69 @@ public class LocalStore extends Store implements Serializable { throw new MessagingException("Cannot call getUidFromMessageId on LocalFolder"); } - private void clearMessagesWhere(final String whereClause, final String[] params) throws MessagingException { + public void clearMessagesOlderThan(long cutoff) throws MessagingException { open(OpenMode.READ_ONLY); + Message[] messages = LocalStore.this.getMessages( null, this, - "SELECT " + GET_MESSAGES_COLS + "FROM messages WHERE (empty IS NULL OR empty != 1) AND (" + whereClause + ")", - params); + "SELECT " + GET_MESSAGES_COLS + + "FROM messages " + + "LEFT JOIN threads ON (threads.message_id = messages.id) " + + "WHERE (empty IS NULL OR empty != 1) AND " + + "(folder_id = ? and date < ?)", + new String[] { + Long.toString(mFolderId), Long.toString(cutoff) + }); for (Message message : messages) { - deleteAttachments(message.getUid()); + message.destroy(); } - database.execute(false, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - db.execSQL("DELETE FROM messages WHERE " + whereClause, params); - return null; - } - }); notifyChange(); } - public void clearMessagesOlderThan(long cutoff) throws MessagingException { - final String where = "folder_id = ? and date < ?"; - final String[] params = new String[] { - Long.toString(mFolderId), Long.toString(cutoff) - }; - - clearMessagesWhere(where, params); - } - - - public void clearAllMessages() throws MessagingException { - final String where = "folder_id = ?"; - final String[] params = new String[] { - Long.toString(mFolderId) - }; + final String[] folderIdArg = new String[] { Long.toString(mFolderId) }; + open(OpenMode.READ_ONLY); + + try { + database.execute(false, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws WrappedException { + try { + // Get UIDs for all messages to delete + Cursor cursor = db.query("messages", new String[] { "uid" }, + "folder_id = ? AND (empty IS NULL OR empty != 1)", + folderIdArg, null, null, null); + + try { + // Delete attachments of these messages + while (cursor.moveToNext()) { + deleteAttachments(cursor.getString(0)); + } + } finally { + cursor.close(); + } + + // Delete entries in 'threads' and 'messages' + db.execSQL("DELETE FROM threads WHERE message_id IN " + + "(SELECT id FROM messages WHERE folder_id = ?)", folderIdArg); + db.execSQL("DELETE FROM messages WHERE folder_id = ?", folderIdArg); + + return null; + } catch (MessagingException e) { + throw new WrappedException(e); + } + } + }); + } catch (WrappedException e) { + throw(MessagingException) e.getCause(); + } + + notifyChange(); - clearMessagesWhere(where, params); setPushState(null); setLastPush(0); setLastChecked(0); @@ -3095,7 +3204,7 @@ public class LocalStore extends Store implements Serializable { String messageId = message.getMessageId(); // If there's already an empty message in the database, update that - long id = getDatabaseIdByMessageId(db, messageId, true); + ThreadInfo msgThreadInfo = getThreadInfo(db, messageId); // Get the message IDs from the "References" header line String[] referencesArray = message.getHeader("References"); @@ -3121,56 +3230,73 @@ public class LocalStore extends Store implements Serializable { if (messageIds == null) { // This is not a reply, nothing to do for us. - return new ThreadInfo(id, messageId, -1, -1); + return (msgThreadInfo != null) ? + msgThreadInfo : new ThreadInfo(-1, -1, messageId, -1, -1); } for (String reference : messageIds) { ThreadInfo threadInfo = getThreadInfo(db, reference); if (threadInfo == null) { - // Create placeholder message + // Create placeholder message in 'messages' table ContentValues cv = new ContentValues(); cv.put("message_id", reference); cv.put("folder_id", mFolderId); cv.put("empty", 1); + long newMsgId = db.insert("messages", null, cv); + + // Create entry in 'threads' table + cv.clear(); + cv.put("message_id", newMsgId); if (rootId != -1) { - cv.put("thread_root", rootId); + cv.put("root", rootId); } if (parentId != -1) { - cv.put("thread_parent", parentId); + cv.put("parent", parentId); } - parentId = db.insert("messages", null, cv); + parentId = db.insert("threads", null, cv); if (rootId == -1) { rootId = parentId; } } else { - if (rootId != -1 && threadInfo.rootId == -1 && rootId != threadInfo.id) { + if (rootId != -1 && threadInfo.rootId == -1 && rootId != threadInfo.threadId) { // We found an existing root container that is not // the root of our current path (References). // Connect it to the current parent. // Let all children know who's the new root ContentValues cv = new ContentValues(); - cv.put("thread_root", rootId); - db.update("messages", cv, "thread_root=?", - new String[] { Long.toString(threadInfo.id) }); + cv.put("root", rootId); + db.update("threads", cv, "root = ?", + new String[] { Long.toString(threadInfo.threadId) }); // Connect the message to the current parent - cv.put("thread_parent", parentId); - db.update("messages", cv, "id=?", - new String[] { Long.toString(threadInfo.id) }); + cv.put("parent", parentId); + db.update("threads", cv, "id = ?", + new String[] { Long.toString(threadInfo.threadId) }); } else { - rootId = (threadInfo.rootId == -1) ? threadInfo.id : threadInfo.rootId; + rootId = (threadInfo.rootId == -1) ? + threadInfo.threadId : threadInfo.rootId; } - parentId = threadInfo.id; + parentId = threadInfo.threadId; } } //TODO: set in-reply-to "link" even if one already exists - return new ThreadInfo(id, messageId, rootId, parentId); + long threadId; + long msgId; + if (msgThreadInfo != null) { + threadId = msgThreadInfo.threadId; + msgId = msgThreadInfo.msgId; + } else { + threadId = -1; + msgId = -1; + } + + return new ThreadInfo(threadId, msgId, messageId, rootId, parentId); } public List extractNewMessages(final List messages) @@ -3281,8 +3407,8 @@ public class LocalStore extends Store implements Serializable { private boolean mHeadersLoaded = false; private boolean mMessageDirty = false; + private long mThreadId; private long mRootId; - private long mParentId; public LocalMessage() { } @@ -3338,8 +3464,8 @@ public class LocalStore extends Store implements Serializable { this.mFolder = f; } - mRootId = (cursor.isNull(15)) ? -1 : cursor.getLong(15); - mParentId = (cursor.isNull(16)) ? -1 : cursor.getLong(16); + mThreadId = (cursor.isNull(15)) ? -1 : cursor.getLong(15); + mRootId = (cursor.isNull(16)) ? -1 : cursor.getLong(16); boolean deleted = (cursor.getInt(17) == 1); boolean read = (cursor.getInt(18) == 1); @@ -3609,97 +3735,47 @@ public class LocalStore extends Store implements Serializable { localFolder.deleteAttachments(mId); - String id = Long.toString(mId); + if (hasThreadChildren(db, mId)) { + // This message has children in the thread structure so we need to + // make it an empty message. + ContentValues cv = new ContentValues(); + cv.put("id", mId); + cv.put("folder_id", localFolder.getId()); + cv.put("deleted", 0); + cv.put("message_id", getMessageId()); + cv.put("empty", 1); - // Check if this message has children in the thread hierarchy - Cursor cursor = db.query("messages", new String[] { "COUNT(id)" }, - "thread_root = ? OR thread_parent = ?", - new String[] {id, id}, - null, null, null); + db.replace("messages", null, cv); - try { - if (cursor.moveToFirst() && cursor.getLong(0) > 0) { - // Make the message an empty message - ContentValues cv = new ContentValues(); - cv.put("id", mId); - cv.put("folder_id", localFolder.getId()); - cv.put("deleted", 0); - cv.put("message_id", getMessageId()); - cv.put("empty", 1); - - if (getRootId() != -1) { - cv.put("thread_root", getRootId()); - } - - if (getParentId() != -1) { - cv.put("thread_parent", getParentId()); - } - - db.replace("messages", null, cv); - - // Nothing else to do - return null; - } - } finally { - cursor.close(); + // Nothing else to do + return null; } - long parentId = getParentId(); + // Get the message ID of the parent message if it's empty + long currentId = getEmptyThreadParent(db, mId); - // Check if 'parentId' is empty - cursor = db.query("messages", new String[] { "id" }, - "id = ? AND empty = 1", - new String[] { Long.toString(parentId) }, - null, null, null); + // Delete the placeholder message + deleteMessageRow(db, mId); - try { - if (cursor.getCount() == 0) { - // If the message isn't empty we skip the loop below - parentId = -1; + /* + * Walk the thread tree to delete all empty parents without children + */ + + while (currentId != -1) { + if (hasThreadChildren(db, currentId)) { + // We made sure there are no empty leaf nodes and can stop now. + break; } - } finally { - cursor.close(); + + // Get ID of the (empty) parent for the next iteration + long newId = getEmptyThreadParent(db, currentId); + + // Delete the empty message + deleteMessageRow(db, currentId); + + currentId = newId; } - while (parentId != -1) { - String parentIdString = Long.toString(parentId); - - // Get the parent of the message 'parentId' - cursor = db.query("messages", new String[] { "thread_parent" }, - "id = ? AND empty = 1", - new String[] { parentIdString }, - null, null, null); - try { - if (cursor.moveToFirst() && !cursor.isNull(0)) { - parentId = cursor.getLong(0); - } else { - parentId = -1; - } - } finally { - cursor.close(); - } - - // Check if (the old) 'parentId' has any children - cursor = db.query("messages", new String[] { "COUNT(id)" }, - "thread_parent = ? AND id != ?", - new String[] { parentIdString, id }, - null, null, null); - - try { - if (cursor.moveToFirst() && cursor.getLong(0) == 0) { - // If it has no children we can remove it - db.delete("messages", "id = ?", - new String[] { parentIdString }); - } else { - break; - } - } finally { - cursor.close(); - } - } - - // Remove the placeholder message - db.delete("messages", "id = ?", new String[] { id }); } catch (MessagingException e) { throw new WrappedException(e); } @@ -3713,6 +3789,77 @@ public class LocalStore extends Store implements Serializable { notifyChange(); } + /** + * Get ID of the the given message's parent if the parent is an empty message. + * + * @param db + * {@link SQLiteDatabase} instance to access the database. + * @param messageId + * The database ID of the message to get the parent for. + * + * @return Message ID of the parent message if there exists a parent and it is empty. + * Otherwise {@code -1}. + */ + private long getEmptyThreadParent(SQLiteDatabase db, long messageId) { + Cursor cursor = db.rawQuery( + "SELECT m.id " + + "FROM threads t1 " + + "JOIN threads t2 ON (t1.parent = t2.id) " + + "LEFT JOIN messages m ON (t2.message_id = m.id) " + + "WHERE t1.message_id = ? AND m.empty = 1", + new String[] { Long.toString(messageId) }); + + try { + return (cursor.moveToFirst() && !cursor.isNull(0)) ? cursor.getLong(0) : -1; + } finally { + cursor.close(); + } + } + + /** + * Check whether or not a message has child messages in the thread structure. + * + * @param db + * {@link SQLiteDatabase} instance to access the database. + * @param messageId + * The database ID of the message to get the children for. + * + * @return {@code true} if the message has children. {@code false} otherwise. + */ + private boolean hasThreadChildren(SQLiteDatabase db, long messageId) { + Cursor cursor = db.rawQuery( + "SELECT COUNT(t2.id) " + + "FROM threads t1 " + + "JOIN threads t2 ON (t2.parent = t1.id) " + + "WHERE t1.message_id = ?", + new String[] { Long.toString(messageId) }); + + try { + return (cursor.moveToFirst() && !cursor.isNull(0) && cursor.getLong(0) > 0L); + } finally { + cursor.close(); + } + } + + /** + * Delete a message from the 'messages' and 'threads' tables. + * + * @param db + * {@link SQLiteDatabase} instance to access the database. + * @param messageId + * The database ID of the message to delete. + */ + private void deleteMessageRow(SQLiteDatabase db, long messageId) { + String[] idArg = { Long.toString(messageId) }; + + // Delete the message + db.delete("messages", "id = ?", idArg); + + // Delete row in 'threads' table + // TODO: create trigger for 'messages' table to get rid of the row in 'threads' table + db.delete("threads", "message_id = ?", idArg); + } + private void loadHeaders() throws UnavailableStorageException { ArrayList messages = new ArrayList(); messages.add(this); @@ -3771,12 +3918,12 @@ public class LocalStore extends Store implements Serializable { return message; } - public long getRootId() { - return mRootId; + public long getThreadId() { + return mThreadId; } - public long getParentId() { - return mParentId; + public long getRootId() { + return mRootId; } } @@ -3846,13 +3993,15 @@ public class LocalStore extends Store implements Serializable { } static class ThreadInfo { - public final long id; + public final long threadId; + public final long msgId; public final String messageId; public final long rootId; public final long parentId; - public ThreadInfo(long id, String messageId, long rootId, long parentId) { - this.id = id; + public ThreadInfo(long threadId, long msgId, String messageId, long rootId, long parentId) { + this.threadId = threadId; + this.msgId = msgId; this.messageId = messageId; this.rootId = rootId; this.parentId = parentId; @@ -3980,10 +4129,7 @@ public class LocalStore extends Store implements Serializable { * *

* The goal of this method is to be fast. Currently this means using as few SQL UPDATE - * statements as possible.
- * Current benchmarks show that updating 1000 messages takes about 8 seconds on a Nexus 7. So - * there should be room for further improvement. - *

+ * statements as possible. * * @param messageIds * A list of primary keys in the "messages" table. @@ -3991,15 +4137,11 @@ public class LocalStore extends Store implements Serializable { * 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. - * @param threadRootIds - * If this is {@code true}, {@code messageIds} contains the IDs of the messages at the - * root of a thread. In that case the flag is changed for all messages in these threads. - * If this is {@code false} only the messages in {@code messageIds} are changed. * * @throws MessagingException */ - public void setFlag(final List messageIds, final Flag flag, - final boolean newState, final boolean threadRootIds) throws MessagingException { + public void setFlag(final List messageIds, final Flag flag, final boolean newState) + throws MessagingException { final ContentValues cv = new ContentValues(); @@ -4043,11 +4185,6 @@ public class LocalStore extends Store implements Serializable { db.update("messages", cv, "(empty IS NULL OR empty != 1) AND id" + selectionSet, selectionArgs); - - if (threadRootIds) { - db.update("messages", cv, "(empty IS NULL OR empty != 1) AND thread_root" + - selectionSet, selectionArgs); - } } @Override @@ -4057,14 +4194,93 @@ public class LocalStore extends Store implements Serializable { }, FLAG_UPDATE_BATCH_SIZE); } + /** + * Change the state of a flag for a list of threads. + * + *

+ * 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 threadRootIds, Flag flag, final boolean newState) + throws MessagingException { + + final String flagColumn; + switch (flag) { + case SEEN: { + flagColumn = "read"; + break; + } + case FLAGGED: { + flagColumn = "flagged"; + break; + } + case ANSWERED: { + flagColumn = "answered"; + break; + } + case FORWARDED: { + flagColumn = "forwarded"; + break; + } + default: { + throw new IllegalArgumentException("Flag must be a special column 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 { + + int len = selectionArgs.length; + String[] args = new String[len * 2]; + System.arraycopy(selectionArgs, 0, args, 0, len); + System.arraycopy(selectionArgs, 0, args, len, len); + + 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 IS NULL OR m.empty != 1) AND m.deleted = 0 " + + "AND (t.id" + selectionSet + " OR t.root" + selectionSet + "))", + args); + } + + @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 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 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. * @@ -4093,18 +4309,29 @@ public class LocalStore extends Store implements Serializable { public void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) throws UnavailableStorageException { - String sqlPrefix = - "SELECT m.uid, f.name " + - "FROM messages m " + - "JOIN folders f ON (m.folder_id = f.id) " + - "WHERE (m.empty IS NULL OR m.empty != 1) AND "; - - String sql = sqlPrefix + "m.id" + selectionSet; - getDataFromCursor(db.rawQuery(sql, selectionArgs)); - if (threadedList) { - String threadSql = sqlPrefix + "m.thread_root" + selectionSet; - getDataFromCursor(db.rawQuery(threadSql, selectionArgs)); + 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 IS NULL OR m.empty != 1) AND m.deleted = 0 " + + "AND (t.id" + selectionSet + " OR t.root" + selectionSet + ")"; + + int len = selectionArgs.length; + String[] args = new String[len * 2]; + System.arraycopy(selectionArgs, 0, args, 0, len); + System.arraycopy(selectionArgs, 0, args, len, len); + + getDataFromCursor(db.rawQuery(sql, args)); + + } else { + String sql = + "SELECT m.uid, f.name " + + "FROM messages m " + + "LEFT JOIN folders f ON (m.folder_id = f.id) " + + "WHERE (m.empty IS NULL OR m.empty != 1) AND m.id" + selectionSet; + + getDataFromCursor(db.rawQuery(sql, selectionArgs)); } } diff --git a/src/com/fsck/k9/provider/EmailProvider.java b/src/com/fsck/k9/provider/EmailProvider.java index e0f5e1c96..9b93df964 100644 --- a/src/com/fsck/k9/provider/EmailProvider.java +++ b/src/com/fsck/k9/provider/EmailProvider.java @@ -54,7 +54,7 @@ public class EmailProvider extends ContentProvider { private static final int MESSAGE_BASE = 0; private static final int MESSAGES = MESSAGE_BASE; private static final int MESSAGES_THREADED = MESSAGE_BASE + 1; - //private static final int MESSAGES_THREAD = MESSAGE_BASE + 2; + private static final int MESSAGES_THREAD = MESSAGE_BASE + 2; private static final int STATS_BASE = 100; private static final int STATS = STATS_BASE; @@ -78,8 +78,6 @@ public class EmailProvider extends ContentProvider { MessageColumns.ATTACHMENT_COUNT, MessageColumns.FOLDER_ID, MessageColumns.PREVIEW, - MessageColumns.THREAD_ROOT, - MessageColumns.THREAD_PARENT, MessageColumns.READ, MessageColumns.FLAGGED, MessageColumns.ANSWERED, @@ -95,6 +93,8 @@ public class EmailProvider extends ContentProvider { MessageColumns.ID }; + private static final String FOLDERS_TABLE = "folders"; + private static final String[] FOLDERS_COLUMNS = { FolderColumns.ID, FolderColumns.NAME, @@ -112,12 +112,21 @@ public class EmailProvider extends ContentProvider { FolderColumns.DISPLAY_CLASS }; + private static final String THREADS_TABLE = "threads"; + + private static final String[] THREADS_COLUMNS = { + ThreadColumns.ID, + ThreadColumns.MESSAGE_ID, + ThreadColumns.ROOT, + ThreadColumns.PARENT + }; + static { UriMatcher matcher = sUriMatcher; matcher.addURI(AUTHORITY, "account/*/messages", MESSAGES); matcher.addURI(AUTHORITY, "account/*/messages/threaded", MESSAGES_THREADED); - //matcher.addURI(AUTHORITY, "account/*/thread/#", MESSAGES_THREAD); + matcher.addURI(AUTHORITY, "account/*/thread/#", MESSAGES_THREAD); matcher.addURI(AUTHORITY, "account/*/stats", STATS); } @@ -125,6 +134,8 @@ public class EmailProvider extends ContentProvider { public interface SpecialColumns { public static final String ACCOUNT_UUID = "account_uuid"; + public static final String THREAD_COUNT = "thread_count"; + public static final String FOLDER_NAME = "name"; public static final String INTEGRATE = "integrate"; } @@ -145,9 +156,6 @@ public class EmailProvider extends ContentProvider { public static final String ATTACHMENT_COUNT = "attachment_count"; public static final String FOLDER_ID = "folder_id"; public static final String PREVIEW = "preview"; - public static final String THREAD_ROOT = "thread_root"; - public static final String THREAD_PARENT = "thread_parent"; - public static final String THREAD_COUNT = "thread_count"; public static final String READ = "read"; public static final String FLAGGED = "flagged"; public static final String ANSWERED = "answered"; @@ -179,6 +187,13 @@ public class EmailProvider extends ContentProvider { public static final String DISPLAY_CLASS = "display_class"; } + public interface ThreadColumns { + public static final String ID = "id"; + public static final String MESSAGE_ID = "message_id"; + public static final String ROOT = "root"; + public static final String PARENT = "parent"; + } + public interface StatsColumns { public static final String UNREAD_COUNT = "unread_count"; public static final String FLAGGED_COUNT = "flagged_count"; @@ -216,7 +231,8 @@ public class EmailProvider extends ContentProvider { Cursor cursor = null; switch (match) { case MESSAGES: - case MESSAGES_THREADED: { + case MESSAGES_THREADED: + case MESSAGES_THREAD: { List segments = uri.getPathSegments(); String accountUuid = segments.get(1); @@ -238,11 +254,16 @@ public class EmailProvider extends ContentProvider { } else if (match == MESSAGES_THREADED) { cursor = getThreadedMessages(accountUuid, dbProjection, selection, selectionArgs, sortOrder); + } else if (match == MESSAGES_THREAD) { + String threadId = segments.get(3); + cursor = getThread(accountUuid, dbProjection, threadId, sortOrder); } else { throw new RuntimeException("Not implemented"); } - cursor.setNotificationUri(contentResolver, uri); + Uri notificationUri = Uri.withAppendedPath(CONTENT_URI, "account/" + accountUuid + + "/messages"); + cursor.setNotificationUri(contentResolver, notificationUri); cursor = new SpecialColumnsCursor(new IdTrickeryCursor(cursor), projection, specialColumns); @@ -328,6 +349,7 @@ public class EmailProvider extends ContentProvider { } query.append(" FROM messages m " + + "JOIN threads t ON (t.message_id = m.id) " + "LEFT JOIN folders f ON (m.folder_id = f.id) " + "WHERE "); query.append(SqlQueryBuilder.addPrefixToSelection(FIXUP_MESSAGES_COLUMNS, @@ -363,6 +385,7 @@ public class EmailProvider extends ContentProvider { UnavailableStorageException { StringBuilder query = new StringBuilder(); + query.append("SELECT "); boolean first = true; for (String columnName : projection) { @@ -372,54 +395,144 @@ public class EmailProvider extends ContentProvider { first = false; } - if (MessageColumns.DATE.equals(columnName)) { - query.append("MAX(m.date) AS " + MessageColumns.DATE); - } else if (MessageColumns.THREAD_COUNT.equals(columnName)) { - query.append("COUNT(h.id) AS " + MessageColumns.THREAD_COUNT); - } else if (SpecialColumns.FOLDER_NAME.equals(columnName)) { - query.append("f." + SpecialColumns.FOLDER_NAME + " AS " + - SpecialColumns.FOLDER_NAME); - } else if (SpecialColumns.INTEGRATE.equals(columnName)) { - query.append("f." + SpecialColumns.INTEGRATE + " AS " + - SpecialColumns.INTEGRATE); + if (MessageColumns.ID.equals(columnName)) { + query.append("u." + MessageColumns.ID + " AS " + MessageColumns.ID); + } else if (MessageColumns.DATE.equals(columnName)) { + query.append("MAX(date) AS " + MessageColumns.DATE); + } else if (SpecialColumns.THREAD_COUNT.equals(columnName)) { + query.append("COUNT(g) AS " + SpecialColumns.THREAD_COUNT); } else { - query.append("m."); - query.append(columnName); - query.append(" AS "); query.append(columnName); } } - query.append( - " FROM messages h JOIN messages m " + - "ON (h.id = m.thread_root OR h.id = m.id) "); + query.append(" FROM ("); - if (Utility.arrayContainsAny(projection, (Object[]) FOLDERS_COLUMNS)) { - query.append("LEFT JOIN folders f ON (m.folder_id = f.id) "); - } + createThreadedSubQuery(projection, selection, selectionArgs, "t1.id = t2.id", query); + query.append(" UNION ALL "); + createThreadedSubQuery(projection, selection, selectionArgs, "t1.id = t2.root", query); - query.append( - "WHERE " + - "(m.deleted = 0 AND " + - "(m.empty IS NULL OR m.empty != 1) AND " + - "h.thread_root IS NULL) "); - - if (!StringUtils.isNullOrEmpty(selection)) { - query.append("AND ("); - query.append(SqlQueryBuilder.addPrefixToSelection(MESSAGES_COLUMNS, - "h.", selection)); - query.append(") "); - } - - query.append("GROUP BY h.id"); + query.append(") u GROUP BY g"); if (!StringUtils.isNullOrEmpty(sortOrder)) { query.append(" ORDER BY "); query.append(SqlQueryBuilder.addPrefixToSelection(MESSAGES_COLUMNS, - "m.", sortOrder)); + "u.", sortOrder)); } - return db.rawQuery(query.toString(), selectionArgs); + // We need the selection arguments twice. Once for each sub query. + String[] args = new String[selectionArgs.length * 2]; + System.arraycopy(selectionArgs, 0, args, 0, selectionArgs.length); + System.arraycopy(selectionArgs, 0, args, selectionArgs.length, selectionArgs.length); + + return db.rawQuery(query.toString(), args); + } + }); + } catch (UnavailableStorageException e) { + throw new RuntimeException("Storage not available", e); + } + } + + private void createThreadedSubQuery(String[] projection, String selection, + String[] selectionArgs, String join, StringBuilder query) { + + query.append("SELECT h." + MessageColumns.ID + " AS g"); + for (String columnName : projection) { + if (SpecialColumns.THREAD_COUNT.equals(columnName)) { + // Skip + } else if (SpecialColumns.FOLDER_NAME.equals(columnName) || + SpecialColumns.INTEGRATE.equals(columnName)) { + query.append("," + columnName); + } else if (ThreadColumns.ROOT.equals(columnName)) { + // Always return the thread ID of the root message (even for the root + // message itself) + query.append(",CASE WHEN t2." + ThreadColumns.ROOT + " IS NULL THEN " + + "t2." + ThreadColumns.ID + " ELSE t2." + ThreadColumns.ROOT + + " END AS " + ThreadColumns.ROOT); + } else { + query.append(",m."); + query.append(columnName); + query.append(" AS "); + query.append(columnName); + } + } + + query.append( + " FROM messages h " + + "LEFT JOIN threads t1 ON (t1.message_id = h.id) " + + "JOIN threads t2 ON ("); + query.append(join); + query.append(") " + + "LEFT JOIN messages m ON (m.id = t2.message_id) "); + + if (Utility.arrayContainsAny(projection, (Object[]) FOLDERS_COLUMNS)) { + query.append("LEFT JOIN folders f ON (m.folder_id = f.id) "); + } + + query.append( + "WHERE " + + "(t1.root IS NULL AND " + + "m.deleted = 0 AND " + + "(m.empty IS NULL OR m.empty != 1))"); + + if (!StringUtils.isNullOrEmpty(selection)) { + query.append(" AND ("); + query.append(SqlQueryBuilder.addPrefixToSelection(MESSAGES_COLUMNS, + "h.", selection)); + query.append(")"); + } + } + + protected Cursor getThread(String accountUuid, final String[] projection, final String threadId, + final String sortOrder) { + + Account account = getAccount(accountUuid); + LockableDatabase database = getDatabase(account); + + try { + return database.execute(false, new DbCallback() { + @Override + public Cursor doDbWork(SQLiteDatabase db) throws WrappedException, + UnavailableStorageException { + + StringBuilder query = new StringBuilder(); + query.append("SELECT "); + boolean first = true; + for (String columnName : projection) { + if (!first) { + query.append(","); + } else { + first = false; + } + + if (MessageColumns.ID.equals(columnName)) { + query.append("m." + MessageColumns.ID + " AS " + MessageColumns.ID); + } else { + query.append(columnName); + } + } + + query.append(" FROM " + THREADS_TABLE + " t JOIN " + MESSAGES_TABLE + " m " + + "ON (m." + MessageColumns.ID + " = t." + ThreadColumns.MESSAGE_ID + + ") "); + + if (Utility.arrayContainsAny(projection, (Object[]) FOLDERS_COLUMNS)) { + query.append("LEFT JOIN " + FOLDERS_TABLE + " f " + + "ON (m." + MessageColumns.FOLDER_ID + " = f." + FolderColumns.ID + + ") "); + } + + query.append("WHERE (t." + ThreadColumns.ID + " = ? OR " + + ThreadColumns.ROOT + " = ?) AND " + + InternalMessageColumns.DELETED + " = 0 AND (" + + InternalMessageColumns.EMPTY + " IS NULL OR " + + InternalMessageColumns.EMPTY + " != 1)"); + + query.append(" ORDER BY "); + query.append(SqlQueryBuilder.addPrefixToSelection(FIXUP_MESSAGES_COLUMNS, + "m.", sortOrder)); + + return db.rawQuery(query.toString(), new String[] { threadId, threadId }); } }); } catch (UnavailableStorageException e) { diff --git a/src/com/fsck/k9/search/SearchSpecification.java b/src/com/fsck/k9/search/SearchSpecification.java index 6c780e745..99edef8a7 100644 --- a/src/com/fsck/k9/search/SearchSpecification.java +++ b/src/com/fsck/k9/search/SearchSpecification.java @@ -76,7 +76,7 @@ public interface SearchSpecification extends Parcelable { MESSAGE_CONTENTS, ATTACHMENT_COUNT, DELETED, - THREAD_ROOT, + THREAD_ID, ID, INTEGRATE, READ, diff --git a/src/com/fsck/k9/search/SqlQueryBuilder.java b/src/com/fsck/k9/search/SqlQueryBuilder.java index 880f22f65..85c7344c2 100644 --- a/src/com/fsck/k9/search/SqlQueryBuilder.java +++ b/src/com/fsck/k9/search/SqlQueryBuilder.java @@ -66,6 +66,12 @@ public class SqlQueryBuilder { } break; } + case THREAD_ID: { + query.append("threads.id = ? OR threads.root = ?"); + selectionArgs.add(condition.value); + selectionArgs.add(condition.value); + break; + } default: { appendCondition(condition, query, selectionArgs); } @@ -149,10 +155,6 @@ public class SqlQueryBuilder { columnName = "subject"; break; } - case THREAD_ROOT: { - columnName = "thread_root"; - break; - } case TO: { columnName = "to_list"; break; @@ -177,6 +179,7 @@ public class SqlQueryBuilder { columnName = "display_class"; break; } + case THREAD_ID: case FOLDER: case SEARCHABLE: { // Special cases handled in buildWhereClauseInternal() @@ -258,7 +261,7 @@ public class SqlQueryBuilder { case FOLDER: case ID: case INTEGRATE: - case THREAD_ROOT: + case THREAD_ID: case READ: case FLAGGED: { return true; @@ -272,7 +275,7 @@ public class SqlQueryBuilder { public static String addPrefixToSelection(String[] columnNames, String prefix, String selection) { String result = selection; for (String columnName : columnNames) { - result = result.replaceAll("\\b" + columnName + "\\b", prefix + columnName); + result = result.replaceAll("(?<=^|[^\\.])\\b" + columnName + "\\b", prefix + columnName); } return result;