Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 756ffc78 authored by cketti's avatar cketti Committed by GitHub
Browse files

Merge pull request #5246 from k9mail/BackendStorage_to_MessageStore

Remove ThreadMessageOperations' dependency on LocalStore
parents 783182a7 78e616ed
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -22,7 +22,7 @@ class K9MessageStore(
) : MessageStore {
    private val database: LockableDatabase = localStore.database
    private val attachmentFileManager = AttachmentFileManager(storageManager, accountUuid)
    private val threadMessageOperations = ThreadMessageOperations(localStore)
    private val threadMessageOperations = ThreadMessageOperations()
    private val moveMessageOperations = MoveMessageOperations(database, threadMessageOperations)
    private val flagMessageOperations = FlagMessageOperations(database)
    private val retrieveMessageOperations = RetrieveMessageOperations(database)
+163 −51
Original line number Diff line number Diff line
@@ -2,84 +2,190 @@ package com.fsck.k9.storage.messages

import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.mailstore.LocalFolder
import com.fsck.k9.mailstore.LocalMessage
import com.fsck.k9.mailstore.LocalStore
import com.fsck.k9.mailstore.ThreadInfo as LegacyThreadInfo
import com.fsck.k9.helper.Utility
import com.fsck.k9.mail.message.MessageHeaderParser
import java.util.Locale

// TODO: Remove dependency on LocalStore
internal class ThreadMessageOperations(private val localStore: LocalStore) {
internal class ThreadMessageOperations {

    fun createOrUpdateParentThreadEntries(
        database: SQLiteDatabase,
        messageId: Long,
        destinationFolderId: Long
    ): ThreadInfo {
        val message = getLocalMessage(database, messageId) ?: error("Couldn't find local message [ID: $messageId]")
        val destinationFolder = getLocalFolder(destinationFolderId)
        val threadHeaders = getMessageThreadHeaders(database, messageId)
        return doMessageThreading(database, destinationFolderId, threadHeaders)
    }

    private fun getMessageThreadHeaders(database: SQLiteDatabase, messageId: Long): ThreadHeaders {
        return database.rawQuery(
            """
            SELECT messages.message_id, message_parts.header 
            FROM messages 
            LEFT JOIN message_parts ON (messages.message_part_id = message_parts.id) 
            WHERE messages.id = ?
            """.trimIndent(),
            arrayOf(messageId.toString()),
        ).use { cursor ->
            if (!cursor.moveToFirst()) error("Message not found: $messageId")

        val legacyThreadInfo: LegacyThreadInfo = destinationFolder.doMessageThreading(database, message)
        return legacyThreadInfo.toThreadInfo()
            val messageIdHeader = cursor.getString(0)
            val headerBytes = cursor.getBlob(1)

            var inReplyToHeader: String? = null
            var referencesHeader: String? = null
            if (headerBytes != null) {
                MessageHeaderParser.parse(headerBytes.inputStream()) { name, value ->
                    when (name.toLowerCase(Locale.ROOT)) {
                        "in-reply-to" -> inReplyToHeader = value
                        "references" -> referencesHeader = value
                    }
                }
            }

    fun createOrUpdateThreadEntry(
        database: SQLiteDatabase,
        destinationMessageId: Long,
        threadInfo: ThreadInfo
    ) {
        val contentValues = ContentValues()
        contentValues.put("message_id", destinationMessageId)
        if (threadInfo.threadId == null) {
            if (threadInfo.rootId != null) {
                contentValues.put("root", threadInfo.rootId)
            ThreadHeaders(messageIdHeader, inReplyToHeader, referencesHeader)
        }
            if (threadInfo.parentId != null) {
                contentValues.put("parent", threadInfo.parentId)
    }
            database.insert("threads", null, contentValues)

    fun createOrUpdateThreadEntry(database: SQLiteDatabase, messageId: Long, threadInfo: ThreadInfo) {
        if (threadInfo.threadId == null) {
            createThreadEntry(database, messageId, threadInfo.rootId, threadInfo.parentId)
        } else {
            val contentValues = ContentValues().apply {
                put("message_id", messageId)
            }

            database.update("threads", contentValues, "id = ?", arrayOf(threadInfo.threadId.toString()))
        }
    }

    private fun getLocalMessage(database: SQLiteDatabase, messageId: Long): LocalMessage? {
        return database.query(
            "messages",
            arrayOf("uid", "folder_id"),
            "id = ?",
            arrayOf(messageId.toString()),
            null, null, null
        ).use { cursor ->
            if (cursor.moveToFirst()) {
                val uid = cursor.getString(0)
                val folderId = cursor.getLong(1)
    private fun createThreadEntry(database: SQLiteDatabase, messageId: Long, rootId: Long?, parentId: Long?): Long {
        val values = ContentValues().apply {
            put("message_id", messageId)
            put("root", rootId)
            put("parent", parentId)
        }

        return database.insert("threads", null, values)
    }

    // TODO: Use MessageIdParser
    private fun doMessageThreading(database: SQLiteDatabase, folderId: Long, threadHeaders: ThreadHeaders): ThreadInfo {
        val messageIdHeader = threadHeaders.messageIdHeader
        val msgThreadInfo = getThreadInfo(database, folderId, messageIdHeader, onlyEmpty = true)

                val folder = getLocalFolder(folderId)
                folder.getMessage(uid)
        val references = threadHeaders.referencesHeader.extractMessageIdValues()
        val inReplyTo = threadHeaders.inReplyToHeader.extractMessageIdValue()

        val messageIdValues = if (inReplyTo == null || inReplyTo in references) {
            references
        } else {
                null
            references + inReplyTo
        }

        if (messageIdValues.isEmpty()) {
            // This is not a reply, nothing to do for us.
            return msgThreadInfo
                ?: ThreadInfo(threadId = null, messageId = null, messageIdHeader, rootId = null, parentId = null)
        }

        var rootId: Long? = null
        var parentId: Long? = null
        for (reference in messageIdValues) {
            val threadInfo = getThreadInfo(database, folderId, reference, onlyEmpty = false)
            if (threadInfo == null) {
                parentId = createEmptyMessage(database, folderId, reference, rootId, parentId)
                if (rootId == null) {
                    rootId = parentId
                }
            } else {
                if (rootId != null && threadInfo.rootId == null && rootId != threadInfo.threadId) {
                    // We found an existing root container that is not the root of our current path (References).
                    updateThreadToNewRoot(database, threadInfo.threadId!!, rootId, parentId)
                } else {
                    rootId = threadInfo.rootId ?: threadInfo.threadId
                }
                parentId = threadInfo.threadId
            }
        }

    private fun getLocalFolder(destinationFolderId: Long): LocalFolder {
        val destinationFolder = localStore.getFolder(destinationFolderId)
        destinationFolder.open()
        // TODO: set in-reply-to "link" even if one already exists

        return destinationFolder
        return ThreadInfo(msgThreadInfo?.threadId, msgThreadInfo?.messageId, messageIdHeader, rootId, parentId)
    }

    private fun LegacyThreadInfo.toThreadInfo(): ThreadInfo {
        return ThreadInfo(
            threadId = threadId.minusOneToNull(),
            messageId = msgId.minusOneToNull(),
            messageIdHeader = messageId,
            rootId = rootId.minusOneToNull(),
            parentId = parentId.minusOneToNull()
        )
    private fun updateThreadToNewRoot(database: SQLiteDatabase, oldRootId: Long, rootId: Long, parentId: Long?) {
        // Let all children know who's the new root
        val values = ContentValues()
        values.put("root", rootId)
        database.update("threads", values, "root = ?", arrayOf(oldRootId.toString()))

        // Connect the message to the current parent
        values.put("parent", parentId)
        database.update("threads", values, "id = ?", arrayOf(oldRootId.toString()))
    }

    private fun createEmptyMessage(
        database: SQLiteDatabase,
        folderId: Long,
        messageIdHeader: String,
        rootId: Long?,
        parentId: Long?
    ): Long {
        val messageValues = ContentValues().apply {
            put("message_id", messageIdHeader)
            put("folder_id", folderId)
            put("empty", 1)
        }
        val messageId = database.insert("messages", null, messageValues)

        val threadValues = ContentValues().apply {
            put("message_id", messageId)
            put("root", rootId)
            put("parent", parentId)
        }
        return database.insert("threads", null, threadValues)
    }

    private fun getThreadInfo(
        db: SQLiteDatabase,
        folderId: Long,
        messageIdHeader: String?,
        onlyEmpty: Boolean
    ): ThreadInfo? {
        if (messageIdHeader == null) return null

        return db.rawQuery(
            """
            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 = ? 
            ${if (onlyEmpty) "AND m.empty = 1 " else ""}
            ORDER BY m.id 
            LIMIT 1
            """.trimIndent(),
            arrayOf(folderId.toString(), messageIdHeader)
        ).use { cursor ->
            if (cursor.moveToFirst()) {
                val threadId = cursor.getLong(0)
                val messageId = cursor.getLong(1)
                val rootId = if (cursor.isNull(2)) null else cursor.getLong(2)
                val parentId = if (cursor.isNull(3)) null else cursor.getLong(3)
                ThreadInfo(threadId, messageId, messageIdHeader, rootId, parentId)
            } else {
                null
            }
        }
    }

    private fun Long.minusOneToNull() = if (this == -1L) null else this
    private fun String?.extractMessageIdValues(): List<String> {
        return this?.let { headerValue -> Utility.extractMessageIds(headerValue) } ?: emptyList()
    }

    private fun String?.extractMessageIdValue(): String? {
        return this?.let { headerValue -> Utility.extractMessageId(headerValue) }
    }
}

internal data class ThreadInfo(
@@ -89,3 +195,9 @@ internal data class ThreadInfo(
    val rootId: Long?,
    val parentId: Long?
)

internal data class ThreadHeaders(
    val messageIdHeader: String?,
    val inReplyToHeader: String?,
    val referencesHeader: String?
)
+59 −53
Original line number Diff line number Diff line
@@ -3,11 +3,7 @@ package com.fsck.k9.storage.messages
import com.fsck.k9.K9
import com.fsck.k9.storage.RobolectricTest
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import org.junit.Test
import org.mockito.ArgumentMatchers.anyLong
import org.junit.Assert.fail as junitFail

private const val SOURCE_FOLDER_ID = 3L
@@ -15,8 +11,9 @@ private const val DESTINATION_FOLDER_ID = 23L
private const val MESSAGE_ID_HEADER = "<00000000-0000-4000-0000-000000000000@domain.example>"

class MoveMessageOperationsTest : RobolectricTest() {
    val sqliteDatabase = createDatabase()
    val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
    private val sqliteDatabase = createDatabase()
    private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
    private val moveMessageOperations = MoveMessageOperations(lockableDatabase, ThreadMessageOperations())

    @Test
    fun `move message not part of a thread`() {
@@ -26,17 +23,8 @@ class MoveMessageOperationsTest : RobolectricTest() {
            subject = "Move me",
            messageIdHeader = MESSAGE_ID_HEADER
        )
        sqliteDatabase.createThread(messageId = originalMessageId)
        val originalMessage = sqliteDatabase.readMessages().first()
        val messageThreader = createMessageThreader(
            ThreadInfo(
                threadId = null,
                messageId = null,
                messageIdHeader = MESSAGE_ID_HEADER,
                rootId = null,
                parentId = null
            )
        )
        val moveMessageOperations = MoveMessageOperations(lockableDatabase, messageThreader)

        val destinationMessageId = moveMessageOperations.moveMessage(
            messageId = originalMessageId,
@@ -46,15 +34,13 @@ class MoveMessageOperationsTest : RobolectricTest() {
        val messages = sqliteDatabase.readMessages()
        assertThat(messages).hasSize(2)

        val sourceMessage = messages.find { it.id == originalMessageId }
            ?: fail("Original message not found")
        val sourceMessage = messages.first { it.id == originalMessageId }
        assertThat(sourceMessage.folderId).isEqualTo(SOURCE_FOLDER_ID)
        assertThat(sourceMessage.uid).isEqualTo("uid1")
        assertThat(sourceMessage.messageId).isEqualTo(MESSAGE_ID_HEADER)
        assertPlaceholderEntry(sourceMessage)

        val destinationMessage = messages.find { it.id == destinationMessageId }
            ?: fail("Destination message not found")
        val destinationMessage = messages.first { it.id == destinationMessageId }
        assertThat(destinationMessage.uid).startsWith(K9.LOCAL_UID_PREFIX)
        assertThat(destinationMessage).isEqualTo(
            originalMessage.copy(
@@ -65,6 +51,17 @@ class MoveMessageOperationsTest : RobolectricTest() {
                empty = 0
            )
        )

        val threads = sqliteDatabase.readThreads()
        assertThat(threads).hasSize(2)

        val originalMessageThread = threads.first { it.messageId == originalMessageId }
        assertThat(originalMessageThread.id).isEqualTo(originalMessageThread.root)
        assertThat(originalMessageThread.parent).isNull()

        val destinationMessageThread = threads.first { it.messageId == destinationMessageId }
        assertThat(destinationMessageThread.id).isEqualTo(destinationMessageThread.root)
        assertThat(destinationMessageThread.parent).isNull()
    }

    @Test
@@ -76,6 +73,7 @@ class MoveMessageOperationsTest : RobolectricTest() {
            messageIdHeader = MESSAGE_ID_HEADER,
            read = false
        )
        sqliteDatabase.createThread(messageId = originalMessageId)
        val originalMessage = sqliteDatabase.readMessages().first()
        val placeholderMessageId = sqliteDatabase.createMessage(
            empty = true,
@@ -83,16 +81,17 @@ class MoveMessageOperationsTest : RobolectricTest() {
            messageIdHeader = MESSAGE_ID_HEADER,
            uid = ""
        )
        val messageThreader = createMessageThreader(
            ThreadInfo(
                threadId = null,
                messageId = placeholderMessageId,
                messageIdHeader = MESSAGE_ID_HEADER,
                rootId = null,
                parentId = null
        val placeholderThreadId = sqliteDatabase.createThread(messageId = placeholderMessageId)
        val childMessageId = sqliteDatabase.createMessage(
            folderId = DESTINATION_FOLDER_ID,
            messageIdHeader = "<msg02@domain.example>",
            uid = "uid2"
        )
        sqliteDatabase.createThread(
            messageId = childMessageId,
            root = placeholderThreadId,
            parent = placeholderThreadId
        )
        val moveMessageOperations = MoveMessageOperations(lockableDatabase, messageThreader)

        val destinationMessageId = moveMessageOperations.moveMessage(
            messageId = originalMessageId,
@@ -100,17 +99,15 @@ class MoveMessageOperationsTest : RobolectricTest() {
        )

        val messages = sqliteDatabase.readMessages()
        assertThat(messages).hasSize(2)
        assertThat(messages).hasSize(3)

        val sourceMessage = messages.find { it.id == originalMessageId }
            ?: fail("Original message not found in database")
        val sourceMessage = messages.first { it.id == originalMessageId }
        assertThat(sourceMessage.folderId).isEqualTo(SOURCE_FOLDER_ID)
        assertThat(sourceMessage.uid).isEqualTo("uid1")
        assertThat(sourceMessage.messageId).isEqualTo(MESSAGE_ID_HEADER)
        assertPlaceholderEntry(sourceMessage)

        val destinationMessage = messages.find { it.id == destinationMessageId }
            ?: fail("Destination message not found in database")
        val destinationMessage = messages.first { it.id == destinationMessageId }
        assertThat(destinationMessage.uid).startsWith(K9.LOCAL_UID_PREFIX)
        assertThat(destinationMessage).isEqualTo(
            originalMessage.copy(
@@ -121,6 +118,21 @@ class MoveMessageOperationsTest : RobolectricTest() {
                empty = 0
            )
        )

        val threads = sqliteDatabase.readThreads()
        assertThat(threads).hasSize(3)

        val originalMessageThread = threads.first { it.messageId == originalMessageId }
        assertThat(originalMessageThread.id).isEqualTo(originalMessageThread.root)
        assertThat(originalMessageThread.parent).isNull()

        val destinationMessageThread = threads.first { it.messageId == destinationMessageId }
        assertThat(destinationMessageThread.id).isEqualTo(destinationMessageThread.root)
        assertThat(destinationMessageThread.parent).isNull()

        val childMessageThread = threads.first { it.messageId == childMessageId }
        assertThat(childMessageThread.root).isEqualTo(destinationMessageThread.id)
        assertThat(childMessageThread.parent).isEqualTo(destinationMessageThread.id)
    }

    @Test
@@ -131,17 +143,8 @@ class MoveMessageOperationsTest : RobolectricTest() {
            subject = "Move me",
            messageIdHeader = null
        )
        sqliteDatabase.createThread(messageId = originalMessageId)
        val originalMessage = sqliteDatabase.readMessages().first()
        val messageThreader = createMessageThreader(
            ThreadInfo(
                threadId = null,
                messageId = null,
                messageIdHeader = null,
                rootId = null,
                parentId = null
            )
        )
        val moveMessageOperations = MoveMessageOperations(lockableDatabase, messageThreader)

        val destinationMessageId = moveMessageOperations.moveMessage(
            messageId = originalMessageId,
@@ -151,14 +154,12 @@ class MoveMessageOperationsTest : RobolectricTest() {
        val messages = sqliteDatabase.readMessages()
        assertThat(messages).hasSize(2)

        val sourceMessage = messages.find { it.id == originalMessageId }
            ?: fail("Original message not found")
        val sourceMessage = messages.first { it.id == originalMessageId }
        assertThat(sourceMessage.folderId).isEqualTo(SOURCE_FOLDER_ID)
        assertThat(sourceMessage.uid).isEqualTo("uid1")
        assertPlaceholderEntry(sourceMessage)

        val destinationMessage = messages.find { it.id == destinationMessageId }
            ?: fail("Destination message not found")
        val destinationMessage = messages.first { it.id == destinationMessageId }
        assertThat(destinationMessage.uid).startsWith(K9.LOCAL_UID_PREFIX)
        assertThat(destinationMessage).isEqualTo(
            originalMessage.copy(
@@ -169,12 +170,17 @@ class MoveMessageOperationsTest : RobolectricTest() {
                empty = 0
            )
        )
    }

    private fun createMessageThreader(threadInfo: ThreadInfo): ThreadMessageOperations {
        return mock {
            on { createOrUpdateParentThreadEntries(any(), anyLong(), anyLong()) } doReturn threadInfo
        }
        val threads = sqliteDatabase.readThreads()
        assertThat(threads).hasSize(2)

        val originalMessageThread = threads.first { it.messageId == originalMessageId }
        assertThat(originalMessageThread.id).isEqualTo(originalMessageThread.root)
        assertThat(originalMessageThread.parent).isNull()

        val destinationMessageThread = threads.first { it.messageId == destinationMessageId }
        assertThat(destinationMessageThread.id).isEqualTo(destinationMessageThread.root)
        assertThat(destinationMessageThread.parent).isNull()
    }

    private fun assertPlaceholderEntry(message: MessageEntry) {