Re-implement copy message functionality in MessageStore
Instead of loading a message into memory and then saving it to the new folder the new code copies the database entries and data files.
This commit is contained in:
parent
9ef105d74f
commit
e05bc461b5
7 changed files with 681 additions and 7 deletions
|
@ -27,6 +27,26 @@ interface MessageStore {
|
|||
*/
|
||||
fun saveLocalMessage(folderId: Long, messageData: SaveMessageData, existingMessageId: Long? = null): Long
|
||||
|
||||
/**
|
||||
* Copy a message to another folder.
|
||||
*
|
||||
* @return The message's database ID in the destination folder.
|
||||
*/
|
||||
fun copyMessage(messageId: Long, destinationFolderId: Long): Long
|
||||
|
||||
/**
|
||||
* Copy messages to another folder.
|
||||
*
|
||||
* @return A mapping of the original message database ID to the new message database ID.
|
||||
*/
|
||||
fun copyMessages(messageIds: Collection<Long>, destinationFolderId: Long): Map<Long, Long> {
|
||||
return messageIds
|
||||
.map { messageId ->
|
||||
messageId to copyMessage(messageId, destinationFolderId)
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a message to another folder.
|
||||
*
|
||||
|
|
|
@ -7,7 +7,7 @@ import com.fsck.k9.mailstore.StorageManager.InternalStorageProvider
|
|||
import java.io.File
|
||||
import timber.log.Timber
|
||||
|
||||
class AttachmentFileManager(
|
||||
internal class AttachmentFileManager(
|
||||
private val storageManager: StorageManager,
|
||||
private val accountUuid: String
|
||||
) {
|
||||
|
@ -23,7 +23,13 @@ class AttachmentFileManager(
|
|||
FileHelper.renameOrMoveByCopying(temporaryFile, destinationFile)
|
||||
}
|
||||
|
||||
private fun getAttachmentFile(messagePartId: Long): File {
|
||||
fun copyFile(sourceMessagePartId: Long, destinationMessagePartId: Long) {
|
||||
val sourceFile = getAttachmentFile(sourceMessagePartId)
|
||||
val destinationFile = getAttachmentFile(destinationMessagePartId)
|
||||
sourceFile.copyTo(destinationFile)
|
||||
}
|
||||
|
||||
fun getAttachmentFile(messagePartId: Long): File {
|
||||
val attachmentDirectory = storageManager.getAttachmentDirectory(accountUuid, InternalStorageProvider.ID)
|
||||
return File(attachmentDirectory, messagePartId.toString())
|
||||
}
|
||||
|
|
|
@ -0,0 +1,315 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.core.database.getBlobOrNull
|
||||
import androidx.core.database.getLongOrNull
|
||||
import androidx.core.database.getStringOrNull
|
||||
import com.fsck.k9.K9
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import java.util.UUID
|
||||
|
||||
internal class CopyMessageOperations(
|
||||
private val lockableDatabase: LockableDatabase,
|
||||
private val attachmentFileManager: AttachmentFileManager,
|
||||
private val threadMessageOperations: ThreadMessageOperations
|
||||
) {
|
||||
fun copyMessage(messageId: Long, destinationFolderId: Long): Long {
|
||||
return lockableDatabase.execute(true) { database ->
|
||||
val newMessageId = copyMessage(database, messageId, destinationFolderId)
|
||||
|
||||
copyFulltextEntry(database, newMessageId, messageId)
|
||||
|
||||
newMessageId
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyMessage(
|
||||
database: SQLiteDatabase,
|
||||
messageId: Long,
|
||||
destinationFolderId: Long
|
||||
): Long {
|
||||
val rootMessagePart = copyMessageParts(database, messageId)
|
||||
|
||||
val threadInfo = threadMessageOperations.doMessageThreading(
|
||||
database,
|
||||
folderId = destinationFolderId,
|
||||
threadHeaders = threadMessageOperations.getMessageThreadHeaders(database, messageId)
|
||||
)
|
||||
|
||||
return if (threadInfo?.messageId != null) {
|
||||
updateMessageRow(
|
||||
database,
|
||||
sourceMessageId = messageId,
|
||||
destinationMessageId = threadInfo.messageId,
|
||||
destinationFolderId,
|
||||
rootMessagePart
|
||||
)
|
||||
} else {
|
||||
val newMessageId = insertMessageRow(
|
||||
database,
|
||||
sourceMessageId = messageId,
|
||||
destinationFolderId = destinationFolderId,
|
||||
rootMessagePartId = rootMessagePart
|
||||
)
|
||||
|
||||
if (threadInfo?.threadId == null) {
|
||||
threadMessageOperations.createThreadEntry(
|
||||
database,
|
||||
newMessageId,
|
||||
threadInfo?.rootId,
|
||||
threadInfo?.parentId
|
||||
)
|
||||
}
|
||||
|
||||
newMessageId
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyMessageParts(database: SQLiteDatabase, messageId: Long): Long {
|
||||
return database.rawQuery(
|
||||
"""
|
||||
SELECT
|
||||
message_parts.id,
|
||||
message_parts.type,
|
||||
message_parts.root,
|
||||
message_parts.parent,
|
||||
message_parts.seq,
|
||||
message_parts.mime_type,
|
||||
message_parts.decoded_body_size,
|
||||
message_parts.display_name,
|
||||
message_parts.header,
|
||||
message_parts.encoding,
|
||||
message_parts.charset,
|
||||
message_parts.data_location,
|
||||
message_parts.data,
|
||||
message_parts.preamble,
|
||||
message_parts.epilogue,
|
||||
message_parts.boundary,
|
||||
message_parts.content_id,
|
||||
message_parts.server_extra
|
||||
FROM messages
|
||||
JOIN message_parts ON (message_parts.root = messages.message_part_id)
|
||||
WHERE messages.id = ?
|
||||
ORDER BY message_parts.seq
|
||||
""".trimIndent(),
|
||||
arrayOf(messageId.toString())
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToNext()) error("No message part found for message with ID $messageId")
|
||||
|
||||
val rootMessagePart = cursor.readMessagePart()
|
||||
|
||||
val rootMessagePartId = writeMessagePart(
|
||||
database = database,
|
||||
databaseMessagePart = rootMessagePart,
|
||||
newRootId = null,
|
||||
newParentId = -1
|
||||
)
|
||||
|
||||
val messagePartIdMapping = mutableMapOf<Long, Long>()
|
||||
messagePartIdMapping[rootMessagePart.id] = rootMessagePartId
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val messagePart = cursor.readMessagePart()
|
||||
|
||||
messagePartIdMapping[messagePart.id] = writeMessagePart(
|
||||
database = database,
|
||||
databaseMessagePart = messagePart,
|
||||
newRootId = rootMessagePartId,
|
||||
newParentId = messagePartIdMapping[messagePart.parent] ?: error("parent ID not found")
|
||||
)
|
||||
}
|
||||
|
||||
rootMessagePartId
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeMessagePart(
|
||||
database: SQLiteDatabase,
|
||||
databaseMessagePart: DatabaseMessagePart,
|
||||
newRootId: Long?,
|
||||
newParentId: Long
|
||||
): Long {
|
||||
val values = ContentValues().apply {
|
||||
put("type", databaseMessagePart.type)
|
||||
put("root", newRootId)
|
||||
put("parent", newParentId)
|
||||
put("seq", databaseMessagePart.seq)
|
||||
put("mime_type", databaseMessagePart.mimeType)
|
||||
put("decoded_body_size", databaseMessagePart.decodedBodySize)
|
||||
put("display_name", databaseMessagePart.displayName)
|
||||
put("header", databaseMessagePart.header)
|
||||
put("encoding", databaseMessagePart.encoding)
|
||||
put("charset", databaseMessagePart.charset)
|
||||
put("data_location", databaseMessagePart.dataLocation)
|
||||
put("data", databaseMessagePart.data)
|
||||
put("preamble", databaseMessagePart.preamble)
|
||||
put("epilogue", databaseMessagePart.epilogue)
|
||||
put("boundary", databaseMessagePart.boundary)
|
||||
put("content_id", databaseMessagePart.contentId)
|
||||
put("server_extra", databaseMessagePart.serverExtra)
|
||||
}
|
||||
|
||||
val messagePartId = database.insert("message_parts", null, values)
|
||||
|
||||
if (databaseMessagePart.dataLocation == DataLocation.ON_DISK) {
|
||||
attachmentFileManager.copyFile(databaseMessagePart.id, messagePartId)
|
||||
}
|
||||
|
||||
return messagePartId
|
||||
}
|
||||
|
||||
private fun updateMessageRow(
|
||||
database: SQLiteDatabase,
|
||||
sourceMessageId: Long,
|
||||
destinationMessageId: Long,
|
||||
destinationFolderId: Long,
|
||||
rootMessagePartId: Long
|
||||
): Long {
|
||||
val values = readMessageToContentValues(database, sourceMessageId, destinationFolderId, rootMessagePartId)
|
||||
|
||||
database.update("messages", values, "id = ?", arrayOf(destinationMessageId.toString()))
|
||||
return destinationMessageId
|
||||
}
|
||||
|
||||
private fun insertMessageRow(
|
||||
database: SQLiteDatabase,
|
||||
sourceMessageId: Long,
|
||||
destinationFolderId: Long,
|
||||
rootMessagePartId: Long
|
||||
): Long {
|
||||
val values = readMessageToContentValues(database, sourceMessageId, destinationFolderId, rootMessagePartId)
|
||||
|
||||
return database.insert("messages", null, values)
|
||||
}
|
||||
|
||||
private fun readMessageToContentValues(
|
||||
database: SQLiteDatabase,
|
||||
sourceMessageId: Long,
|
||||
destinationFolderId: Long,
|
||||
rootMessagePartId: Long
|
||||
): ContentValues {
|
||||
val values = readMessageToContentValues(database, sourceMessageId)
|
||||
|
||||
return values.apply {
|
||||
put("folder_id", destinationFolderId)
|
||||
put("uid", K9.LOCAL_UID_PREFIX + UUID.randomUUID().toString())
|
||||
put("message_part_id", rootMessagePartId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFulltextEntry(database: SQLiteDatabase, newMessageId: Long, messageId: Long) {
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT OR REPLACE INTO messages_fulltext (docid, fulltext)
|
||||
SELECT ?, fulltext FROM messages_fulltext WHERE docid = ?
|
||||
""".trimIndent(),
|
||||
arrayOf(newMessageId.toString(), messageId.toString())
|
||||
)
|
||||
}
|
||||
|
||||
private fun readMessageToContentValues(database: SQLiteDatabase, messageId: Long): ContentValues {
|
||||
return database.query(
|
||||
"messages",
|
||||
arrayOf(
|
||||
"deleted",
|
||||
"subject",
|
||||
"date",
|
||||
"flags",
|
||||
"sender_list",
|
||||
"to_list",
|
||||
"cc_list",
|
||||
"bcc_list",
|
||||
"reply_to_list",
|
||||
"attachment_count",
|
||||
"internal_date",
|
||||
"message_id",
|
||||
"preview_type",
|
||||
"preview",
|
||||
"mime_type",
|
||||
"normalized_subject_hash",
|
||||
"empty",
|
||||
"read",
|
||||
"flagged",
|
||||
"answered",
|
||||
"forwarded",
|
||||
"encryption_type"
|
||||
),
|
||||
"id = ?",
|
||||
arrayOf(messageId.toString()),
|
||||
null, null, null
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToNext()) error("Message with ID $messageId not found")
|
||||
|
||||
ContentValues().apply {
|
||||
put("deleted", cursor.getInt(0))
|
||||
put("subject", cursor.getStringOrNull(1))
|
||||
put("date", cursor.getLong(2))
|
||||
put("flags", cursor.getStringOrNull(3))
|
||||
put("sender_list", cursor.getStringOrNull(4))
|
||||
put("to_list", cursor.getStringOrNull(5))
|
||||
put("cc_list", cursor.getStringOrNull(6))
|
||||
put("bcc_list", cursor.getStringOrNull(7))
|
||||
put("reply_to_list", cursor.getStringOrNull(8))
|
||||
put("attachment_count", cursor.getInt(9))
|
||||
put("internal_date", cursor.getLong(10))
|
||||
put("message_id", cursor.getStringOrNull(11))
|
||||
put("preview_type", cursor.getStringOrNull(12))
|
||||
put("preview", cursor.getStringOrNull(13))
|
||||
put("mime_type", cursor.getStringOrNull(14))
|
||||
put("normalized_subject_hash", cursor.getLong(15))
|
||||
put("empty", cursor.getInt(16))
|
||||
put("read", cursor.getInt(17))
|
||||
put("flagged", cursor.getInt(18))
|
||||
put("answered", cursor.getInt(19))
|
||||
put("forwarded", cursor.getInt(20))
|
||||
put("encryption_type", cursor.getStringOrNull(21))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor.readMessagePart(): DatabaseMessagePart {
|
||||
return DatabaseMessagePart(
|
||||
id = getLong(0),
|
||||
type = getInt(1),
|
||||
root = getLong(2),
|
||||
parent = getLong(3),
|
||||
seq = getInt(4),
|
||||
mimeType = getString(5),
|
||||
decodedBodySize = getLongOrNull(6),
|
||||
displayName = getStringOrNull(7),
|
||||
header = getBlobOrNull(8),
|
||||
encoding = getStringOrNull(9),
|
||||
charset = getStringOrNull(10),
|
||||
dataLocation = getInt(11),
|
||||
data = getBlobOrNull(12),
|
||||
preamble = getBlobOrNull(13),
|
||||
epilogue = getBlobOrNull(14),
|
||||
boundary = getStringOrNull(15),
|
||||
contentId = getStringOrNull(16),
|
||||
serverExtra = getStringOrNull(17)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class DatabaseMessagePart(
|
||||
val id: Long,
|
||||
val type: Int,
|
||||
val root: Long,
|
||||
val parent: Long,
|
||||
val seq: Int,
|
||||
val mimeType: String?,
|
||||
val decodedBodySize: Long?,
|
||||
val displayName: String?,
|
||||
val header: ByteArray?,
|
||||
val encoding: String?,
|
||||
val charset: String?,
|
||||
val dataLocation: Int,
|
||||
val data: ByteArray?,
|
||||
val preamble: ByteArray?,
|
||||
val epilogue: ByteArray?,
|
||||
val boundary: String?,
|
||||
val contentId: String?,
|
||||
val serverExtra: String?
|
||||
)
|
|
@ -33,6 +33,7 @@ class K9MessageStore(
|
|||
basicPartInfoExtractor,
|
||||
threadMessageOperations
|
||||
)
|
||||
private val copyMessageOperations = CopyMessageOperations(database, attachmentFileManager, threadMessageOperations)
|
||||
private val moveMessageOperations = MoveMessageOperations(database, threadMessageOperations)
|
||||
private val flagMessageOperations = FlagMessageOperations(database)
|
||||
private val retrieveMessageOperations = RetrieveMessageOperations(database)
|
||||
|
@ -52,6 +53,12 @@ class K9MessageStore(
|
|||
return saveMessageOperations.saveLocalMessage(folderId, messageData, existingMessageId)
|
||||
}
|
||||
|
||||
override fun copyMessage(messageId: Long, destinationFolderId: Long): Long {
|
||||
return copyMessageOperations.copyMessage(messageId, destinationFolderId).also {
|
||||
localStore.notifyChange()
|
||||
}
|
||||
}
|
||||
|
||||
override fun moveMessage(messageId: Long, destinationFolderId: Long): Long {
|
||||
return moveMessageOperations.moveMessage(messageId, destinationFolderId).also {
|
||||
localStore.notifyChange()
|
||||
|
|
|
@ -18,7 +18,7 @@ internal class ThreadMessageOperations {
|
|||
return doMessageThreading(database, destinationFolderId, threadHeaders)
|
||||
}
|
||||
|
||||
private fun getMessageThreadHeaders(database: SQLiteDatabase, messageId: Long): ThreadHeaders {
|
||||
fun getMessageThreadHeaders(database: SQLiteDatabase, messageId: Long): ThreadHeaders {
|
||||
return database.rawQuery(
|
||||
"""
|
||||
SELECT messages.message_id, message_parts.header
|
||||
|
|
|
@ -0,0 +1,248 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import com.fsck.k9.mail.crlf
|
||||
import com.fsck.k9.mailstore.StorageManager
|
||||
import com.fsck.k9.storage.RobolectricTest
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import org.junit.After
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.anyOrNull
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
|
||||
private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000"
|
||||
|
||||
class CopyMessageOperationsTest : RobolectricTest() {
|
||||
private val messagePartDirectory = createRandomTempDirectory()
|
||||
private val sqliteDatabase = createDatabase()
|
||||
private val storageManager = mock<StorageManager> {
|
||||
on { getAttachmentDirectory(eq(ACCOUNT_UUID), anyOrNull()) } doReturn messagePartDirectory
|
||||
}
|
||||
private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
|
||||
private val attachmentFileManager = AttachmentFileManager(storageManager, ACCOUNT_UUID)
|
||||
private val threadMessageOperations = ThreadMessageOperations()
|
||||
private val copyMessageOperations = CopyMessageOperations(
|
||||
lockableDatabase,
|
||||
attachmentFileManager,
|
||||
threadMessageOperations
|
||||
)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
messagePartDirectory.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copy message that is part of a thread`() {
|
||||
val sourceMessagePartId1 = sqliteDatabase.createMessagePart(
|
||||
seq = 0,
|
||||
dataLocation = DataLocation.CHILD_PART_CONTAINS_DATA,
|
||||
mimeType = "multipart/mixed",
|
||||
boundary = "--boundary",
|
||||
header = "Message-ID: <msg0002@domain.example>\nIn-Reply-To: <msg0001@domain.example>\n".crlf()
|
||||
)
|
||||
val sourceMessagePartId2 = sqliteDatabase.createMessagePart(
|
||||
seq = 1,
|
||||
root = sourceMessagePartId1,
|
||||
parent = sourceMessagePartId1,
|
||||
mimeType = "text/plain",
|
||||
dataLocation = DataLocation.IN_DATABASE,
|
||||
data = "Text part".toByteArray()
|
||||
)
|
||||
val sourceMessagePartId3 = sqliteDatabase.createMessagePart(
|
||||
seq = 2,
|
||||
root = sourceMessagePartId1,
|
||||
parent = sourceMessagePartId1,
|
||||
mimeType = "application/octet-stream",
|
||||
dataLocation = DataLocation.ON_DISK
|
||||
)
|
||||
attachmentFileManager.getAttachmentFile(sourceMessagePartId3).sink().buffer().use { sink ->
|
||||
sink.writeUtf8("Part contents")
|
||||
}
|
||||
|
||||
val messageId1 = sqliteDatabase.createMessage(
|
||||
folderId = 1,
|
||||
empty = true,
|
||||
messageIdHeader = "<msg0001@domain.example>"
|
||||
)
|
||||
val messageId2 = sqliteDatabase.createMessage(
|
||||
folderId = 1,
|
||||
empty = false,
|
||||
messageIdHeader = "<msg0002@domain.example>",
|
||||
messagePartId = sourceMessagePartId1
|
||||
)
|
||||
val messageId3 = sqliteDatabase.createMessage(
|
||||
folderId = 1,
|
||||
empty = false,
|
||||
messageIdHeader = "<msg0003@domain.example>"
|
||||
)
|
||||
val threadId1 = sqliteDatabase.createThread(messageId1)
|
||||
val threadId2 = sqliteDatabase.createThread(messageId2, root = threadId1, parent = threadId1)
|
||||
val threadId3 = sqliteDatabase.createThread(messageId3, root = threadId1, parent = threadId2)
|
||||
|
||||
val destinationMessageId = copyMessageOperations.copyMessage(messageId = messageId2, destinationFolderId = 2)
|
||||
|
||||
assertThat(destinationMessageId).isNotIn(setOf(messageId1, messageId2, messageId3))
|
||||
|
||||
val threads = sqliteDatabase.readThreads()
|
||||
assertThat(threads).hasSize(3 + 2)
|
||||
|
||||
val destinationMessageThread = threads.first { it.messageId == destinationMessageId }
|
||||
assertThat(destinationMessageThread.id).isNotIn(setOf(threadId1, threadId2, threadId3))
|
||||
assertThat(destinationMessageThread.parent).isEqualTo(destinationMessageThread.root)
|
||||
|
||||
val destinationRootThread = threads.first { it.id == destinationMessageThread.root }
|
||||
assertThat(destinationRootThread.messageId).isNotIn(setOf(messageId1, messageId2, messageId3))
|
||||
assertThat(destinationRootThread.root).isEqualTo(destinationRootThread.id)
|
||||
assertThat(destinationRootThread.parent).isNull()
|
||||
|
||||
val messages = sqliteDatabase.readMessages()
|
||||
val destinationRootThreadMessage = messages.first { it.id == destinationRootThread.messageId }
|
||||
assertThat(destinationRootThreadMessage.empty).isEqualTo(1)
|
||||
assertThat(destinationRootThreadMessage.folderId).isEqualTo(2)
|
||||
assertThat(destinationRootThreadMessage.messageId).isEqualTo("<msg0001@domain.example>")
|
||||
|
||||
val destinationMessage = messages.first { it.id == destinationMessageThread.messageId }
|
||||
val sourceMessage = messages.first { it.id == messageId2 }
|
||||
assertThat(destinationMessage).isEqualTo(
|
||||
sourceMessage.copy(
|
||||
id = destinationMessageId,
|
||||
uid = destinationMessage.uid,
|
||||
folderId = 2,
|
||||
messagePartId = destinationMessage.messagePartId
|
||||
)
|
||||
)
|
||||
|
||||
val messageParts = sqliteDatabase.readMessageParts()
|
||||
assertThat(messageParts).hasSize(3 + 3)
|
||||
|
||||
val sourceMessagePart1 = messageParts.first { it.id == sourceMessagePartId1 }
|
||||
val sourceMessagePart2 = messageParts.first { it.id == sourceMessagePartId2 }
|
||||
val sourceMessagePart3 = messageParts.first { it.id == sourceMessagePartId3 }
|
||||
val destinationMessagePart1 = messageParts.first { it.id == destinationMessage.messagePartId }
|
||||
val destinationMessagePart2 = messageParts.first { it.root == destinationMessage.messagePartId && it.seq == 1 }
|
||||
val destinationMessagePart3 = messageParts.first { it.root == destinationMessage.messagePartId && it.seq == 2 }
|
||||
assertThat(destinationMessagePart1).isNotIn(setOf(sourceMessagePart1, sourceMessagePart2, sourceMessagePart3))
|
||||
assertThat(destinationMessagePart1).isEqualTo(
|
||||
sourceMessagePart1.copy(
|
||||
id = destinationMessagePart1.id,
|
||||
root = destinationMessagePart1.id,
|
||||
parent = -1
|
||||
)
|
||||
)
|
||||
assertThat(destinationMessagePart2).isNotIn(setOf(sourceMessagePart1, sourceMessagePart2, sourceMessagePart3))
|
||||
assertThat(destinationMessagePart2).isEqualTo(
|
||||
sourceMessagePart2.copy(
|
||||
id = destinationMessagePart2.id,
|
||||
root = destinationMessagePart1.id,
|
||||
parent = destinationMessagePart1.id
|
||||
)
|
||||
)
|
||||
assertThat(destinationMessagePart3).isNotIn(setOf(sourceMessagePart1, sourceMessagePart2, sourceMessagePart3))
|
||||
assertThat(destinationMessagePart3).isEqualTo(
|
||||
sourceMessagePart3.copy(
|
||||
id = destinationMessagePart3.id,
|
||||
root = destinationMessagePart1.id,
|
||||
parent = destinationMessagePart1.id
|
||||
)
|
||||
)
|
||||
|
||||
val files = messagePartDirectory.list()?.toList() ?: emptyList()
|
||||
assertThat(files).hasSize(2)
|
||||
|
||||
attachmentFileManager.getAttachmentFile(destinationMessagePart3.id!!).source().buffer().use { source ->
|
||||
assertThat(source.readUtf8()).isEqualTo("Part contents")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copy message into an existing thread`() {
|
||||
val sourceMessagePartId = sqliteDatabase.createMessagePart(
|
||||
header = "Message-ID: <msg0002@domain.example>\nIn-Reply-To: <msg0001@domain.example>\n".crlf(),
|
||||
mimeType = "text/plain",
|
||||
dataLocation = DataLocation.IN_DATABASE,
|
||||
data = "Text part".toByteArray()
|
||||
)
|
||||
attachmentFileManager.getAttachmentFile(sourceMessagePartId).sink().buffer().use { sink ->
|
||||
sink.writeUtf8("Part contents")
|
||||
}
|
||||
|
||||
val sourceMessageId = sqliteDatabase.createMessage(
|
||||
folderId = 1,
|
||||
empty = false,
|
||||
messageIdHeader = "<msg0002@domain.example>",
|
||||
messagePartId = sourceMessagePartId
|
||||
)
|
||||
val destinationMessageId = sqliteDatabase.createMessage(
|
||||
folderId = 2,
|
||||
empty = true,
|
||||
messageIdHeader = "<msg0002@domain.example>"
|
||||
)
|
||||
val otherDestinationMessageId = sqliteDatabase.createMessage(
|
||||
folderId = 2,
|
||||
empty = false,
|
||||
messageIdHeader = "<msg0003@domain.example>"
|
||||
)
|
||||
val destinationThreadId = sqliteDatabase.createThread(destinationMessageId)
|
||||
val otherDestinationThreadId = sqliteDatabase.createThread(
|
||||
otherDestinationMessageId,
|
||||
root = destinationThreadId,
|
||||
parent = destinationThreadId
|
||||
)
|
||||
|
||||
val resultMessageId = copyMessageOperations.copyMessage(messageId = sourceMessageId, destinationFolderId = 2)
|
||||
|
||||
assertThat(resultMessageId).isEqualTo(destinationMessageId)
|
||||
|
||||
val threads = sqliteDatabase.readThreads()
|
||||
assertThat(threads).hasSize(2 + 1)
|
||||
|
||||
val destinationThread = threads.first { it.messageId == destinationMessageId }
|
||||
assertThat(destinationThread.id).isEqualTo(destinationThreadId)
|
||||
assertThat(destinationThread.parent).isEqualTo(destinationThread.root)
|
||||
|
||||
val destinationRootThread = threads.first { it.id == destinationThread.root }
|
||||
assertThat(destinationRootThread.root).isEqualTo(destinationRootThread.id)
|
||||
assertThat(destinationRootThread.parent).isNull()
|
||||
|
||||
val otherDestinationThread = threads.first { it.id == otherDestinationThreadId }
|
||||
assertThat(otherDestinationThread.root).isEqualTo(destinationRootThread.id)
|
||||
assertThat(otherDestinationThread.parent).isEqualTo(destinationThread.id)
|
||||
|
||||
val messages = sqliteDatabase.readMessages()
|
||||
assertThat(messages).hasSize(3 + 1)
|
||||
|
||||
val destinationRootThreadMessage = messages.first { it.id == destinationRootThread.messageId }
|
||||
assertThat(destinationRootThreadMessage.empty).isEqualTo(1)
|
||||
assertThat(destinationRootThreadMessage.folderId).isEqualTo(2)
|
||||
assertThat(destinationRootThreadMessage.messageId).isEqualTo("<msg0001@domain.example>")
|
||||
|
||||
val destinationMessage = messages.first { it.id == destinationMessageId }
|
||||
val sourceMessage = messages.first { it.id == sourceMessageId }
|
||||
assertThat(destinationMessage).isEqualTo(
|
||||
sourceMessage.copy(
|
||||
id = destinationMessageId,
|
||||
uid = destinationMessage.uid,
|
||||
folderId = 2,
|
||||
messagePartId = destinationMessage.messagePartId
|
||||
)
|
||||
)
|
||||
|
||||
val messageParts = sqliteDatabase.readMessageParts()
|
||||
assertThat(messageParts).hasSize(1 + 1)
|
||||
|
||||
val sourceMessagePart = messageParts.first { it.id == sourceMessagePartId }
|
||||
val destinationMessagePart = messageParts.first { it.id == destinationMessage.messagePartId }
|
||||
assertThat(destinationMessagePart).isEqualTo(
|
||||
sourceMessagePart.copy(
|
||||
id = destinationMessagePart.id,
|
||||
root = destinationMessagePart.id,
|
||||
parent = -1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -10,8 +10,8 @@ import com.fsck.k9.helper.map
|
|||
|
||||
fun SQLiteDatabase.createMessagePart(
|
||||
type: Int = MessagePartType.UNKNOWN,
|
||||
root: Int? = null,
|
||||
parent: Int = -1,
|
||||
root: Long? = null,
|
||||
parent: Long = -1,
|
||||
seq: Int = 0,
|
||||
mimeType: String? = null,
|
||||
decodedBodySize: Int? = null,
|
||||
|
@ -77,7 +77,7 @@ fun SQLiteDatabase.readMessageParts(): List<MessagePartEntry> {
|
|||
}
|
||||
}
|
||||
|
||||
class MessagePartEntry(
|
||||
data class MessagePartEntry(
|
||||
val id: Long?,
|
||||
val type: Int?,
|
||||
val root: Long?,
|
||||
|
@ -96,7 +96,85 @@ class MessagePartEntry(
|
|||
val boundary: String?,
|
||||
val contentId: String?,
|
||||
val serverExtra: String?
|
||||
)
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as MessagePartEntry
|
||||
|
||||
if (id != other.id) return false
|
||||
if (type != other.type) return false
|
||||
if (root != other.root) return false
|
||||
if (parent != other.parent) return false
|
||||
if (seq != other.seq) return false
|
||||
if (mimeType != other.mimeType) return false
|
||||
if (decodedBodySize != other.decodedBodySize) return false
|
||||
if (displayName != other.displayName) return false
|
||||
if (header != null) {
|
||||
if (other.header == null) return false
|
||||
if (!header.contentEquals(other.header)) return false
|
||||
} else if (other.header != null) return false
|
||||
if (encoding != other.encoding) return false
|
||||
if (charset != other.charset) return false
|
||||
if (dataLocation != other.dataLocation) return false
|
||||
if (data != null) {
|
||||
if (other.data == null) return false
|
||||
if (!data.contentEquals(other.data)) return false
|
||||
} else if (other.data != null) return false
|
||||
if (preamble != other.preamble) return false
|
||||
if (epilogue != other.epilogue) return false
|
||||
if (boundary != other.boundary) return false
|
||||
if (contentId != other.contentId) return false
|
||||
if (serverExtra != other.serverExtra) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id?.hashCode() ?: 0
|
||||
result = 31 * result + (type ?: 0)
|
||||
result = 31 * result + (root?.hashCode() ?: 0)
|
||||
result = 31 * result + (parent?.hashCode() ?: 0)
|
||||
result = 31 * result + (seq ?: 0)
|
||||
result = 31 * result + (mimeType?.hashCode() ?: 0)
|
||||
result = 31 * result + (decodedBodySize ?: 0)
|
||||
result = 31 * result + (displayName?.hashCode() ?: 0)
|
||||
result = 31 * result + (header?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (encoding?.hashCode() ?: 0)
|
||||
result = 31 * result + (charset?.hashCode() ?: 0)
|
||||
result = 31 * result + (dataLocation ?: 0)
|
||||
result = 31 * result + (data?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (preamble?.hashCode() ?: 0)
|
||||
result = 31 * result + (epilogue?.hashCode() ?: 0)
|
||||
result = 31 * result + (boundary?.hashCode() ?: 0)
|
||||
result = 31 * result + (contentId?.hashCode() ?: 0)
|
||||
result = 31 * result + (serverExtra?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "MessagePartEntry(" +
|
||||
"id=$id, " +
|
||||
"type=$type, " +
|
||||
"root=$root, " +
|
||||
"parent=$parent, " +
|
||||
"seq=$seq, " +
|
||||
"mimeType=$mimeType, " +
|
||||
"decodedBodySize=$decodedBodySize, " +
|
||||
"displayName=$displayName, " +
|
||||
"header=${header?.decodeToString()}, " +
|
||||
"encoding=$encoding, " +
|
||||
"charset=$charset, " +
|
||||
"dataLocation=$dataLocation, " +
|
||||
"data=${data?.decodeToString()}, " +
|
||||
"preamble=$preamble, " +
|
||||
"epilogue=$epilogue, " +
|
||||
"boundary=$boundary, " +
|
||||
"contentId=$contentId, " +
|
||||
"serverExtra=$serverExtra)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor.getBlobOrNull(columnName: String): ByteArray? {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
|
|
Loading…
Reference in a new issue