diff --git a/res/layout/message_list_item.xml b/res/layout/message_list_item.xml index 89c7bf619..14ce2996e 100644 --- a/res/layout/message_list_item.xml +++ b/res/layout/message_list_item.xml @@ -45,14 +45,32 @@ android:layout_height="wrap_content" android:layout_toRightOf="@+id/chip_wrapper" android:layout_below="@+id/subject" + android:layout_toLeftOf="@+id/thread_count" android:layout_marginLeft="1dip" android:layout_marginBottom="3dip" - android:layout_marginRight="16dip" - android:layout_alignParentRight="true" + android:layout_marginRight="3dip" android:bufferType="spannable" android:singleLine="false" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="?android:attr/textColorPrimary" /> + + + + - - - diff --git a/src/com/fsck/k9/activity/MessageList.java b/src/com/fsck/k9/activity/MessageList.java index 8862314e9..eb7c91ee4 100644 --- a/src/com/fsck/k9/activity/MessageList.java +++ b/src/com/fsck/k9/activity/MessageList.java @@ -748,4 +748,12 @@ public class MessageList extends K9FragmentActivity implements MessageListFragme return true; } + + @Override + public void showThread(Account account, String folderName, long threadRootId) { + MessageListFragment fragment = MessageListFragment.newInstance(account, folderName, + threadRootId); + + addMessageListFragment(fragment); + } } diff --git a/src/com/fsck/k9/controller/MessagingController.java b/src/com/fsck/k9/controller/MessagingController.java index d5860a4c3..e4cf48734 100644 --- a/src/com/fsck/k9/controller/MessagingController.java +++ b/src/com/fsck/k9/controller/MessagingController.java @@ -517,13 +517,16 @@ public class MessagingController implements Runnable { * @param account * @param folder * @param listener + * @param threaded + * @param threadId * @throws MessagingException */ - public void listLocalMessages(final Account account, final String folder, final MessagingListener listener) { + public void listLocalMessages(final Account account, final String folder, + final MessagingListener listener, final boolean threaded, final long threadId) { threadPool.execute(new Runnable() { @Override public void run() { - listLocalMessagesSynchronous(account, folder, listener); + listLocalMessagesSynchronous(account, folder, listener, threaded, threadId); } }); } @@ -535,9 +538,12 @@ public class MessagingController implements Runnable { * @param account * @param folder * @param listener + * @param threaded + * @param threadId * @throws MessagingException */ - public void listLocalMessagesSynchronous(final Account account, final String folder, final MessagingListener listener) { + public void listLocalMessagesSynchronous(final Account account, final String folder, + final MessagingListener listener, boolean threaded, long threadId) { for (MessagingListener l : getListeners(listener)) { l.listLocalMessagesStarted(account, folder); @@ -588,10 +594,15 @@ public class MessagingController implements Runnable { //Purging followed by getting requires 2 DB queries. //TODO: Fix getMessages to allow auto-pruning at visible limit? localFolder.purgeToVisibleLimit(null); - localFolder.getMessages( - retrievalListener, - false // Skip deleted messages - ); + + if (threadId != -1) { + localFolder.getMessagesInThread(threadId, retrievalListener); + } else if (threaded) { + localFolder.getThreadedMessages(retrievalListener); + } else { + localFolder.getMessages(retrievalListener, false); + } + if (K9.DEBUG) Log.v(K9.LOG_TAG, "Got ack that callbackRunner finished"); diff --git a/src/com/fsck/k9/fragment/MessageListFragment.java b/src/com/fsck/k9/fragment/MessageListFragment.java index df8d85b33..87510e5a2 100644 --- a/src/com/fsck/k9/fragment/MessageListFragment.java +++ b/src/com/fsck/k9/fragment/MessageListFragment.java @@ -76,6 +76,7 @@ import com.fsck.k9.mail.Message; import com.fsck.k9.mail.Store; import com.fsck.k9.mail.store.LocalStore; import com.fsck.k9.mail.store.LocalStore.LocalFolder; +import com.fsck.k9.mail.store.LocalStore.LocalMessage; import com.handmark.pulltorefresh.library.PullToRefreshBase; import com.handmark.pulltorefresh.library.PullToRefreshListView; @@ -94,6 +95,19 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick return fragment; } + public static MessageListFragment newInstance(Account account, String folderName, + long threadRootId) { + MessageListFragment fragment = new MessageListFragment(); + + Bundle args = new Bundle(); + args.putString(ARG_ACCOUNT, account.getUuid()); + args.putString(ARG_FOLDER, folderName); + args.putLong(ARG_THREAD_ID, threadRootId); + fragment.setArguments(args); + + return fragment; + } + public static MessageListFragment newInstance(String title, String[] accountUuids, String[] folderNames, String queryString, Flag[] flags, Flag[] forbiddenFlags, boolean integrate) { @@ -290,6 +304,7 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick private static final String ARG_ACCOUNT_UUIDS = "accountUuids"; private static final String ARG_FOLDER_NAMES = "folderNames"; private static final String ARG_TITLE = "title"; + private static final String ARG_THREAD_ID = "thread_id"; private static final String STATE_LIST_POSITION = "listPosition"; @@ -389,6 +404,11 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick private DateFormat mTimeFormat; + //TODO: make this a setting + private boolean mThreadViewEnabled = true; + + private long mThreadId; + /** * This class is used to run operations that modify UI elements in the UI thread. @@ -667,6 +687,10 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick final MessageInfoHolder message = (MessageInfoHolder) parent.getItemAtPosition(position); if (mSelectedCount > 0) { toggleMessageSelect(message); + } else if (((LocalMessage) message.message).getThreadCount() > 1) { + Folder folder = message.message.getFolder(); + long rootId = ((LocalMessage) message.message).getRootId(); + mFragmentListener.showThread(folder.getAccount(), folder.getName(), rootId); } else { onOpenMessage(message); } @@ -730,6 +754,7 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick mRemoteSearch = args.getBoolean(ARG_REMOTE_SEARCH, false); mSearchAccount = args.getString(ARG_SEARCH_ACCOUNT); mSearchFolder = args.getString(ARG_SEARCH_FOLDER); + mThreadId = args.getLong(ARG_THREAD_ID, -1); String accountUuid = args.getString(ARG_ACCOUNT); @@ -892,7 +917,8 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick //TODO: Support flag based search mRemoteSearchFuture = mController.searchRemoteMessages(mSearchAccount, mSearchFolder, mQueryString, null, null, mAdapter.mListener); } else if (mFolderName != null) { - mController.listLocalMessages(mAccount, mFolderName, mAdapter.mListener); + mController.listLocalMessages(mAccount, mFolderName, mAdapter.mListener, mThreadViewEnabled, mThreadId); + // Hide the archive button if we don't have an archive folder. if (!mAccount.hasArchiveFolder()) { // mBatchArchiveButton.setVisibility(View.GONE); @@ -914,7 +940,7 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick @Override public void run() { if (mFolderName != null) { - mController.listLocalMessagesSynchronous(mAccount, mFolderName, mAdapter.mListener); + mController.listLocalMessagesSynchronous(mAccount, mFolderName, mAdapter.mListener, mThreadViewEnabled, mThreadId); } else if (mQueryString != null) { mController.searchLocalMessagesSynchronous(mAccountUuids, mFolderNames, null, mQueryString, mIntegrate, mQueryFlags, mForbiddenFlags, mAdapter.mListener); } @@ -1929,6 +1955,7 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick holder.preview.setLines(mPreviewLines); holder.preview.setTextSize(TypedValue.COMPLEX_UNIT_SP, mFontSizes.getMessageListPreview()); + holder.threadCount = (TextView) view.findViewById(R.id.thread_count); view.setTag(holder); } @@ -2029,6 +2056,15 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick subject = message.message.getSubject(); } + int threadCount = ((LocalMessage) message.message).getThreadCount(); + if (threadCount > 1) { + holder.threadCount.setText(Integer.toString(threadCount)); + holder.threadCount.setVisibility(View.VISIBLE); + } else { + holder.threadCount.setVisibility(View.GONE); + } + + // We'll get badge support soon --jrv // if (holder.badge != null) { // String email = message.counterpartyAddress; @@ -2133,6 +2169,7 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick public TextView date; public View chip; public int position = -1; + public TextView threadCount; @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { @@ -2902,6 +2939,7 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick public interface MessageListFragmentListener { void setMessageListProgress(int level); + void showThread(Account account, String folderName, long rootId); void remoteSearch(String searchAccount, String searchFolder, String queryString); void showMoreFromSameSender(String senderAddress); void onResendMessage(Message message); diff --git a/src/com/fsck/k9/helper/Utility.java b/src/com/fsck/k9/helper/Utility.java index 113032d34..5a66a0ab9 100644 --- a/src/com/fsck/k9/helper/Utility.java +++ b/src/com/fsck/k9/helper/Utility.java @@ -18,7 +18,9 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.UnsupportedEncodingException; +import java.util.ArrayList; import java.util.Date; +import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -666,4 +668,44 @@ public class Utility { return false; } } + + private static final Pattern MESSAGE_ID = Pattern.compile("<" + + "(?:" + + "[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+" + + "(?:\\.[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+)*" + + "|" + + "\"(?:[^\\\\\"]|\\\\.)*\"" + + ")" + + "@" + + "(?:" + + "[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+" + + "(?:\\.[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+)*" + + "|" + + "\\[(?:[^\\\\\\]]|\\\\.)*\\]" + + ")" + + ">"); + + public static List extractMessageIds(final String text) { + List messageIds = new ArrayList(); + Matcher matcher = MESSAGE_ID.matcher(text); + + int start = 0; + while (matcher.find(start)) { + String messageId = text.substring(matcher.start(), matcher.end()); + messageIds.add(messageId); + start = matcher.end(); + } + + return messageIds; + } + + public static String extractMessageId(final String text) { + Matcher matcher = MESSAGE_ID.matcher(text); + + if (matcher.find()) { + return text.substring(matcher.start(), matcher.end()); + } + + return null; + } } diff --git a/src/com/fsck/k9/mail/store/ImapStore.java b/src/com/fsck/k9/mail/store/ImapStore.java index 906cf8b04..39fab2478 100644 --- a/src/com/fsck/k9/mail/store/ImapStore.java +++ b/src/com/fsck/k9/mail/store/ImapStore.java @@ -1478,7 +1478,7 @@ public class ImapStore extends Store { fetchFields.add("INTERNALDATE"); fetchFields.add("RFC822.SIZE"); fetchFields.add("BODY.PEEK[HEADER.FIELDS (date subject from content-type to cc " + - "reply-to message-id " + K9.IDENTITY_HEADER + ")]"); + "reply-to message-id references " + K9.IDENTITY_HEADER + ")]"); } if (fp.contains(FetchProfile.Item.STRUCTURE)) { fetchFields.add("BODYSTRUCTURE"); diff --git a/src/com/fsck/k9/mail/store/LocalStore.java b/src/com/fsck/k9/mail/store/LocalStore.java index 6e16c1cc7..fe69d8af3 100644 --- a/src/com/fsck/k9/mail/store/LocalStore.java +++ b/src/com/fsck/k9/mail/store/LocalStore.java @@ -13,10 +13,8 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.Date; import java.util.HashMap; -import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Locale; @@ -82,11 +80,8 @@ public class LocalStore extends Store implements Serializable { private static final long serialVersionUID = -5142141896809423072L; private static final Message[] EMPTY_MESSAGE_ARRAY = new Message[0]; - - /** - * Immutable empty {@link String} array - */ private static final String[] EMPTY_STRING_ARRAY = new String[0]; + private static final Flag[] EMPTY_FLAG_ARRAY = new Flag[0]; private static final Flag[] PERMANENT_FLAGS = { Flag.DELETED, Flag.X_DESTROYED, Flag.SEEN, Flag.FLAGGED }; @@ -96,13 +91,13 @@ public class LocalStore extends Store implements Serializable { */ static private String GET_MESSAGES_COLS = "subject, sender_list, date, uid, flags, id, to_list, cc_list, " - + "bcc_list, reply_to_list, attachment_count, internal_date, message_id, folder_id, preview "; + + "bcc_list, reply_to_list, attachment_count, internal_date, message_id, folder_id, preview, thread_root, thread_parent, empty "; static private String GET_FOLDER_COLS = "id, name, unread_count, visible_limit, last_updated, status, push_state, last_pushed, flagged_count, integrate, top_group, poll_class, push_class, display_class"; - protected static final int DB_VERSION = 43; + protected static final int DB_VERSION = 44; protected String uUid = null; @@ -166,10 +161,31 @@ public class LocalStore extends Store implements Serializable { db.execSQL("CREATE INDEX IF NOT EXISTS folder_name ON folders (name)"); db.execSQL("DROP TABLE IF EXISTS messages"); - db.execSQL("CREATE TABLE messages (id INTEGER PRIMARY KEY, deleted INTEGER default 0, folder_id INTEGER, uid TEXT, subject TEXT, " - + "date INTEGER, flags TEXT, sender_list TEXT, to_list TEXT, cc_list TEXT, bcc_list TEXT, reply_to_list TEXT, " - + "html_content TEXT, text_content TEXT, attachment_count INTEGER, internal_date INTEGER, message_id TEXT, preview TEXT, " - + "mime_type TEXT)"); + db.execSQL("CREATE TABLE messages (" + + "id INTEGER PRIMARY KEY, " + + "deleted INTEGER default 0, " + + "folder_id INTEGER, " + + "uid TEXT, " + + "subject TEXT, " + + "date INTEGER, " + + "flags TEXT, " + + "sender_list TEXT, " + + "to_list TEXT, " + + "cc_list TEXT, " + + "bcc_list TEXT, " + + "reply_to_list TEXT, " + + "html_content TEXT, " + + "text_content TEXT, " + + "attachment_count INTEGER, " + + "internal_date INTEGER, " + + "message_id TEXT, " + + "preview TEXT, " + + "mime_type TEXT, "+ + "thread_root INTEGER, " + + "thread_parent INTEGER, " + + "normalized_subject_hash INTEGER, " + + "empty INTEGER" + + ")"); db.execSQL("DROP TABLE IF EXISTS headers"); db.execSQL("CREATE TABLE headers (id INTEGER PRIMARY KEY, message_id INTEGER, name TEXT, value TEXT)"); @@ -179,6 +195,9 @@ public class LocalStore extends Store implements Serializable { db.execSQL("DROP INDEX IF EXISTS msg_folder_id"); db.execSQL("DROP INDEX IF EXISTS msg_folder_id_date"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_folder_id_deleted_date ON messages (folder_id,deleted,internal_date)"); + + //TODO: add indices for the thread columns + 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," @@ -364,6 +383,18 @@ public class LocalStore extends Store implements Serializable { Log.e(K9.LOG_TAG, "Error trying to fix the outbox folders", e); } } + if (db.getVersion() < 44) { + try { + db.execSQL("ALTER TABLE messages ADD thread_root INTEGER"); + db.execSQL("ALTER TABLE messages ADD thread_parent INTEGER"); + db.execSQL("ALTER TABLE messages ADD normalized_subject_hash INTEGER"); + db.execSQL("ALTER TABLE messages ADD empty INTEGER"); + } catch (SQLiteException e) { + if (! e.getMessage().startsWith("duplicate column name:")) { + throw e; + } + } + } } } catch (SQLiteException e) { Log.e(K9.LOG_TAG, "Exception while upgrading database. Resetting the DB to v0"); @@ -942,7 +973,7 @@ public class LocalStore extends Store implements Serializable { null, "SELECT " + GET_MESSAGES_COLS - + "FROM messages WHERE deleted = 0 " + whereClause.toString() + " ORDER BY date DESC" + + "FROM messages WHERE (empty IS NULL OR empty != 1) AND deleted = 0 " + whereClause.toString() + " ORDER BY date DESC" , args.toArray(EMPTY_STRING_ARRAY) ); } @@ -1285,7 +1316,7 @@ public class LocalStore extends Store implements Serializable { } Cursor cursor = null; try { - cursor = db.rawQuery("SELECT COUNT(*) FROM messages WHERE deleted = 0 and folder_id = ?", + cursor = db.rawQuery("SELECT COUNT(*) FROM messages WHERE (empty IS NULL OR empty != 1) AND deleted = 0 and folder_id = ?", new String[] { Long.toString(mFolderId) }); @@ -1901,7 +1932,7 @@ public class LocalStore extends Store implements Serializable { listener, LocalFolder.this, "SELECT " + GET_MESSAGES_COLS - + "FROM messages WHERE " + + "FROM messages WHERE (empty IS NULL OR empty != 1) AND " + (includeDeleted ? "" : "deleted = 0 AND ") + " folder_id = ? ORDER BY date DESC" , new String[] { @@ -1918,6 +1949,75 @@ public class LocalStore extends Store implements Serializable { } } + public Message[] getThreadedMessages(final MessageRetrievalListener listener) + throws MessagingException { + try { + return database.execute(false, new DbCallback() { + @Override + public Message[] doDbWork(final SQLiteDatabase db) throws WrappedException, + UnavailableStorageException { + try { + open(OpenMode.READ_WRITE); + + return LocalStore.this.getMessages( + listener, + LocalFolder.this, + "SELECT " + + "m.subject, m.sender_list, MAX(m.date), m.uid, m.flags, " + + "m.id, m.to_list, m.cc_list, m.bcc_list, m.reply_to_list, " + + "m.attachment_count, m.internal_date, m.message_id, " + + "m.folder_id, m.preview, m.thread_root, m.thread_parent, " + + "m.empty, COUNT(h.id) " + + "FROM messages h JOIN messages m " + + "ON (h.id = m.thread_root OR h.id = m.id) " + + "WHERE h.deleted = 0 AND m.deleted = 0 AND " + + "(m.empty IS NULL OR m.empty != 1) AND " + + "h.thread_root IS NULL AND h.folder_id = ? " + + "GROUP BY h.id", + new String[] { Long.toString(mFolderId) }); + } catch (MessagingException e) { + throw new WrappedException(e); + } + } + }); + } catch (WrappedException e) { + throw(MessagingException) e.getCause(); + } + } + + public Message[] getMessagesInThread(final long threadId, + final MessageRetrievalListener listener) throws MessagingException { + try { + return database.execute(false, new DbCallback() { + @Override + public Message[] doDbWork(final SQLiteDatabase db) throws WrappedException, + UnavailableStorageException { + try { + open(OpenMode.READ_WRITE); + + String threadIdString = Long.toString(threadId); + + return LocalStore.this.getMessages( + listener, + LocalFolder.this, + "SELECT " + GET_MESSAGES_COLS + " " + + "FROM messages " + + "WHERE deleted = 0 AND (empty IS NULL OR empty != 1) AND " + + "(thread_root = ? OR id = ?) AND folder_id = ?", + new String[] { + threadIdString, + threadIdString, + Long.toString(mFolderId) + }); + } catch (MessagingException e) { + throw new WrappedException(e); + } + } + }); + } catch (WrappedException e) { + throw(MessagingException) e.getCause(); + } + } @Override public Message[] getMessages(String[] uids, MessageRetrievalListener listener) @@ -1975,30 +2075,118 @@ public class LocalStore extends Store implements Serializable { String oldUID = message.getUid(); - if (K9.DEBUG) + if (K9.DEBUG) { Log.d(K9.LOG_TAG, "Updating folder_id to " + lDestFolder.getId() + " for message with UID " + message.getUid() + ", id " + lMessage.getId() + " currently in folder " + getName()); + } String newUid = K9.LOCAL_UID_PREFIX + UUID.randomUUID().toString(); message.setUid(newUid); uidMap.put(oldUID, newUid); - db.execSQL("UPDATE messages " + "SET folder_id = ?, uid = ? " + "WHERE id = ?", new Object[] { - lDestFolder.getId(), - message.getUid(), - lMessage.getId() - }); + // Message threading in the target folder + ThreadInfo threadInfo = lDestFolder.doMessageThreading(db, message); + + /* + * "Move" the message into the new folder and thread structure + */ + long id = lMessage.getId(); + String[] idArg = new String[] { Long.toString(id) }; + + 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) }; + + cv.clear(); + cv.put("thread_root", id); + int x = db.update("messages", cv, "thread_root = ?", oldIdArg); + + cv.clear(); + cv.put("thread_parent", id); + x = db.update("messages", cv, "thread_parent = ?", oldIdArg); + } /* * Add a placeholder message so we won't download the original * message again if we synchronize before the remote move is * complete. */ - LocalMessage placeHolder = new LocalMessage(oldUID, LocalFolder.this); - placeHolder.setFlagInternal(Flag.DELETED, true); - placeHolder.setFlagInternal(Flag.SEEN, true); - appendMessages(new Message[] { placeHolder }); + + // We need to open this folder to get the folder id + open(OpenMode.READ_WRITE); + + cv.clear(); + cv.put("uid", oldUID); + cv.put("flags", serializeFlags(new Flag[] { Flag.DELETED, Flag.SEEN })); + cv.put("deleted", 1); + cv.put("folder_id", mFolderId); + + 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) { + // There already existed an empty message in the target folder. + // Let's use it as placeholder. + + newId = threadInfo.id; + + db.update("messages", cv, "id = ?", + new String[] { Long.toString(newId) }); + } else { + newId = db.insert("messages", null, cv); + } + + /* + * Replace all "thread links" to the original message with links to + * the placeholder message. + */ + + 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); } } catch (MessagingException e) { throw new WrappedException(e); @@ -2076,6 +2264,70 @@ public class LocalStore extends Store implements Serializable { } } + /** + * Get the (database) ID of the message with the specified message ID. + * + * @param db + * A {@link SQLiteDatabase} instance to access the database. + * @param messageId + * The message ID to search for. + * @param onlyEmptyMessages + * {@code true} if only "empty messages" (placeholders for threading) should be + * searched + * + * @return If exactly one message with the specified message ID was found, the database ID + * of this message; {@code -1} otherwise. + */ + private long getDatabaseIdByMessageId(SQLiteDatabase db, String messageId, + boolean onlyEmptyMessages) { + long id = -1; + + Cursor cursor = db.query("messages", + new String[] { "id" }, + (onlyEmptyMessages) ? + "empty=1 AND folder_id=? AND message_id=?" : + "folder_id=? AND message_id=?", + new String[] { Long.toString(mFolderId), messageId }, + null, null, null); + + if (cursor != null) { + try { + if (cursor.getCount() == 1) { + cursor.moveToFirst(); + id = cursor.getLong(0); + } + } finally { + cursor.close(); + } + } + + return id; + } + + 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); + + 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); + + return new ThreadInfo(id, messageId, rootId, parentId); + } + } finally { + cursor.close(); + } + } + + return null; + } /** * The method differs slightly from the contract; If an incoming message already has a uid @@ -2143,6 +2395,17 @@ public class LocalStore extends Store implements Serializable { deleteAttachments(message.getUid()); } + long rootId = -1; + long parentId = -1; + + if (oldMessageId == -1) { + // This is a new message. Do the message threading. + ThreadInfo threadInfo = doMessageThreading(db, message); + oldMessageId = threadInfo.id; + rootId = threadInfo.rootId; + parentId = threadInfo.parentId; + } + boolean isDraft = (message.getHeader(K9.IDENTITY_HEADER) != null); List attachments; @@ -2176,7 +2439,7 @@ public class LocalStore extends Store implements Serializable { cv.put("sender_list", Address.pack(message.getFrom())); cv.put("date", message.getSentDate() == null ? System.currentTimeMillis() : message.getSentDate().getTime()); - cv.put("flags", Utility.combine(message.getFlags(), ',').toUpperCase(Locale.US)); + cv.put("flags", serializeFlags(message.getFlags())); cv.put("deleted", message.isSet(Flag.DELETED) ? 1 : 0); cv.put("folder_id", mFolderId); cv.put("to_list", Address.pack(message.getRecipients(RecipientType.TO))); @@ -2190,11 +2453,20 @@ public class LocalStore extends Store implements Serializable { cv.put("internal_date", message.getInternalDate() == null ? System.currentTimeMillis() : message.getInternalDate().getTime()); cv.put("mime_type", message.getMimeType()); + cv.put("empty", 0); String messageId = message.getMessageId(); if (messageId != null) { cv.put("message_id", messageId); } + + if (rootId != -1) { + cv.put("thread_root", rootId); + } + if (parentId != -1) { + cv.put("thread_parent", parentId); + } + long messageUid; if (oldMessageId == -1) { @@ -2270,7 +2542,7 @@ public class LocalStore extends Store implements Serializable { message.getSentDate() == null ? System .currentTimeMillis() : message.getSentDate() .getTime(), - Utility.combine(message.getFlags(), ',').toUpperCase(Locale.US), + serializeFlags(message.getFlags()), mFolderId, Address.pack(message .getRecipients(RecipientType.TO)), @@ -2337,7 +2609,8 @@ public class LocalStore extends Store implements Serializable { db.execSQL("UPDATE messages " + "SET flags = ? " + " WHERE id = ?", new Object[] - { Utility.combine(appendedFlags.toArray(), ',').toUpperCase(Locale.US), id }); + { serializeFlags(appendedFlags.toArray(EMPTY_FLAG_ARRAY)), id }); + return null; } }); @@ -2581,7 +2854,7 @@ public class LocalStore extends Store implements Serializable { Message[] messages = LocalStore.this.getMessages( null, this, - "SELECT " + GET_MESSAGES_COLS + "FROM messages WHERE " + whereClause, + "SELECT " + GET_MESSAGES_COLS + "FROM messages WHERE (empty IS NULL OR empty != 1) AND (" + whereClause + ")", params); for (Message message : messages) { @@ -2883,6 +3156,95 @@ public class LocalStore extends Store implements Serializable { }); } + private String serializeFlags(Flag[] flags) { + return Utility.combine(flags, ',').toUpperCase(Locale.US); + } + + private ThreadInfo doMessageThreading(SQLiteDatabase db, Message message) + throws MessagingException { + long rootId = -1; + long parentId = -1; + + String messageId = message.getMessageId(); + + // If there's already an empty message in the database, update that + long id = getDatabaseIdByMessageId(db, messageId, true); + + // Get the message IDs from the "References" header line + String[] referencesArray = message.getHeader("References"); + List messageIds = null; + if (referencesArray != null && referencesArray.length > 0) { + messageIds = Utility.extractMessageIds(referencesArray[0]); + } + + // Append the first message ID from the "In-Reply-To" header line + String[] inReplyToArray = message.getHeader("In-Reply-To"); + String inReplyTo = null; + if (inReplyToArray != null && inReplyToArray.length > 0) { + inReplyTo = Utility.extractMessageId(inReplyToArray[0]); + if (inReplyTo != null) { + if (messageIds == null) { + messageIds = new ArrayList(1); + messageIds.add(inReplyTo); + } else if (!messageIds.contains(inReplyTo)) { + messageIds.add(inReplyTo); + } + } + } + + if (messageIds == null) { + // This is not a reply, nothing to do for us. + return new ThreadInfo(id, messageId, -1, -1); + } + + for (String reference : messageIds) { + ThreadInfo threadInfo = getThreadInfo(db, reference); + + if (threadInfo == null) { + // Create placeholder message + ContentValues cv = new ContentValues(); + cv.put("message_id", reference); + cv.put("folder_id", mFolderId); + cv.put("empty", 1); + + if (rootId != -1) { + cv.put("thread_root", rootId); + } + if (parentId != -1) { + cv.put("thread_parent", parentId); + } + + parentId = db.insert("messages", null, cv); + if (rootId == -1) { + rootId = parentId; + } + } else { + if (rootId != -1 && threadInfo.rootId == -1 && rootId != threadInfo.id) { + // 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) }); + + // Connect the message to the current parent + cv.put("thread_parent", parentId); + db.update("messages", cv, "id=?", + new String[] { Long.toString(threadInfo.id) }); + } else { + rootId = (threadInfo.rootId == -1) ? threadInfo.id : threadInfo.rootId; + } + parentId = threadInfo.id; + } + } + + //TODO: set in-reply-to "link" even if one already exists + + return new ThreadInfo(id, messageId, rootId, parentId); + } } public static class LocalTextBody extends TextBody { @@ -2930,6 +3292,11 @@ public class LocalStore extends Store implements Serializable { private boolean mHeadersLoaded = false; private boolean mMessageDirty = false; + private long mRootId; + private long mParentId; + private boolean mEmpty; + private int mThreadCount = 0; + public LocalMessage() { } @@ -2983,6 +3350,15 @@ public class LocalStore extends Store implements Serializable { f.open(LocalFolder.OpenMode.READ_WRITE); this.mFolder = f; } + + mRootId = (cursor.isNull(15)) ? -1 : cursor.getLong(15); + mParentId = (cursor.isNull(16)) ? -1 : cursor.getLong(16); + + mEmpty = (cursor.isNull(17)) ? false : (cursor.getInt(17) == 1); + + if (cursor.getColumnCount() > 18) { + mThreadCount = cursor.getInt(18); + } } /** @@ -3236,13 +3612,26 @@ public class LocalStore extends Store implements Serializable { try { database.execute(true, new DbCallback() { @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - db.execSQL("UPDATE messages SET " + "deleted = 1," + "subject = NULL, " - + "sender_list = NULL, " + "date = NULL, " + "to_list = NULL, " - + "cc_list = NULL, " + "bcc_list = NULL, " + "preview = NULL, " - + "html_content = NULL, " + "text_content = NULL, " - + "reply_to_list = NULL " + "WHERE id = ?", new Object[] - { mId }); + public Void doDbWork(final SQLiteDatabase db) throws WrappedException, + UnavailableStorageException { + String[] idArg = new String[] { Long.toString(mId) }; + + ContentValues cv = new ContentValues(); + cv.put("deleted", 1); + cv.put("empty", 1); + cv.putNull("subject"); + cv.putNull("sender_list"); + cv.putNull("date"); + cv.putNull("to_list"); + cv.putNull("cc_list"); + cv.putNull("bcc_list"); + cv.putNull("preview"); + cv.putNull("html_content"); + cv.putNull("text_content"); + cv.putNull("reply_to_list"); + + db.update("messages", cv, "id = ?", idArg); + /* * Delete all of the message's attachments to save space. * We do this explicit deletion here because we're not deleting the record @@ -3253,8 +3642,8 @@ public class LocalStore extends Store implements Serializable { } catch (MessagingException e) { throw new WrappedException(e); } - db.execSQL("DELETE FROM attachments WHERE message_id = ?", new Object[] - { mId }); + + db.delete("attachments", "message_id = ?", idArg); return null; } }); @@ -3262,12 +3651,12 @@ public class LocalStore extends Store implements Serializable { throw(MessagingException) e.getCause(); } ((LocalFolder)mFolder).deleteHeaders(mId); - - } /* * Completely remove a message from the local database + * + * TODO: document how this updates the thread structure */ @Override public void destroy() throws MessagingException { @@ -3277,9 +3666,102 @@ public class LocalStore extends Store implements Serializable { public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { try { + LocalFolder localFolder = (LocalFolder) mFolder; + updateFolderCountsOnFlag(Flag.X_DESTROYED, true); - ((LocalFolder) mFolder).deleteAttachments(mId); - db.execSQL("DELETE FROM messages WHERE id = ?", new Object[] { mId }); + localFolder.deleteAttachments(mId); + + String id = Long.toString(mId); + + // 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); + + 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(); + } + + long parentId = getParentId(); + + // Check if 'parentId' is empty + cursor = db.query("messages", new String[] { "id" }, + "id = ? AND empty = 1", + new String[] { Long.toString(parentId) }, + null, null, null); + + try { + if (cursor.getCount() == 0) { + // If the message isn't empty we skip the loop below + parentId = -1; + } + } finally { + cursor.close(); + } + + 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); } @@ -3385,6 +3867,22 @@ public class LocalStore extends Store implements Serializable { return message; } + + public long getRootId() { + return mRootId; + } + + public long getParentId() { + return mParentId; + } + + public boolean isEmpty() { + return mEmpty; + } + + public int getThreadCount() { + return mThreadCount; + } } public static class LocalAttachmentBodyPart extends MimeBodyPart { @@ -3449,4 +3947,19 @@ public class LocalStore extends Store implements Serializable { return mUri; } } + + static class ThreadInfo { + public final long id; + 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; + this.messageId = messageId; + this.rootId = rootId; + this.parentId = parentId; + } + } + }