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;
+ }
+ }
+
}