Change K9BackendFolder.saveMessage() to use MessageStore
This commit is contained in:
parent
78e616ed37
commit
4e4babeea6
24 changed files with 1230 additions and 30 deletions
|
@ -14,12 +14,12 @@ import com.fsck.k9.mailstore.MoreMessages as StoreMoreMessages
|
|||
class K9BackendFolder(
|
||||
localStore: LocalStore,
|
||||
private val messageStore: MessageStore,
|
||||
private val saveMessageDataCreator: SaveMessageDataCreator,
|
||||
folderServerId: String
|
||||
) : BackendFolder {
|
||||
private val database = localStore.database
|
||||
private val databaseId: String
|
||||
private val folderId: Long
|
||||
private val localFolder = localStore.getFolder(folderServerId)
|
||||
override val name: String
|
||||
override val visibleLimit: Int
|
||||
|
||||
|
@ -91,24 +91,19 @@ class K9BackendFolder(
|
|||
messageStore.setMessageFlag(folderId, messageServerId, flag, value)
|
||||
}
|
||||
|
||||
// TODO: Move implementation from LocalFolder to this class
|
||||
override fun saveCompleteMessage(message: Message) {
|
||||
requireMessageServerId(message)
|
||||
|
||||
localFolder.appendMessages(listOf(message))
|
||||
|
||||
val localMessage = localFolder.getMessage(message.uid)
|
||||
localMessage.setFlag(Flag.X_DOWNLOADED_FULL, true)
|
||||
saveMessage(message, partialMessage = false)
|
||||
}
|
||||
|
||||
// TODO: Move implementation from LocalFolder to this class
|
||||
override fun savePartialMessage(message: Message) {
|
||||
saveMessage(message, partialMessage = true)
|
||||
}
|
||||
|
||||
private fun saveMessage(message: Message, partialMessage: Boolean) {
|
||||
requireMessageServerId(message)
|
||||
|
||||
localFolder.appendMessages(listOf(message))
|
||||
|
||||
val localMessage = localFolder.getMessage(message.uid)
|
||||
localMessage.setFlag(Flag.X_DOWNLOADED_PARTIAL, true)
|
||||
val messageData = saveMessageDataCreator.createSaveMessageData(message, partialMessage)
|
||||
messageStore.saveRemoteMessage(folderId, message.uid, messageData)
|
||||
}
|
||||
|
||||
override fun getOldestMessageDate(): Date? {
|
||||
|
|
|
@ -10,10 +10,11 @@ class K9BackendStorage(
|
|||
private val localStore: LocalStore,
|
||||
private val messageStore: MessageStore,
|
||||
private val folderSettingsProvider: FolderSettingsProvider,
|
||||
private val saveMessageDataCreator: SaveMessageDataCreator,
|
||||
private val listeners: List<BackendFoldersRefreshListener>
|
||||
) : BackendStorage {
|
||||
override fun getFolder(folderServerId: String): BackendFolder {
|
||||
return K9BackendFolder(localStore, messageStore, folderServerId)
|
||||
return K9BackendFolder(localStore, messageStore, saveMessageDataCreator, folderServerId)
|
||||
}
|
||||
|
||||
override fun getFolderServerIds(): List<String> {
|
||||
|
|
|
@ -8,7 +8,8 @@ class K9BackendStorageFactory(
|
|||
private val folderRepositoryManager: FolderRepositoryManager,
|
||||
private val localStoreProvider: LocalStoreProvider,
|
||||
private val messageStoreManager: MessageStoreManager,
|
||||
private val specialFolderSelectionStrategy: SpecialFolderSelectionStrategy
|
||||
private val specialFolderSelectionStrategy: SpecialFolderSelectionStrategy,
|
||||
private val saveMessageDataCreator: SaveMessageDataCreator
|
||||
) {
|
||||
fun createBackendStorage(account: Account): K9BackendStorage {
|
||||
val folderRepository = folderRepositoryManager.getFolderRepository(account)
|
||||
|
@ -24,6 +25,6 @@ class K9BackendStorageFactory(
|
|||
val specialFolderListener = SpecialFolderBackendFoldersRefreshListener(specialFolderUpdater)
|
||||
val autoExpandFolderListener = AutoExpandFolderBackendFoldersRefreshListener(preferences, account, folderRepository)
|
||||
val listeners = listOf(specialFolderListener, autoExpandFolderListener)
|
||||
return K9BackendStorage(localStore, messageStore, folderSettingsProvider, listeners)
|
||||
return K9BackendStorage(localStore, messageStore, folderSettingsProvider, saveMessageDataCreator, listeners)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
package com.fsck.k9.mailstore
|
||||
|
||||
import com.fsck.k9.message.extractors.AttachmentCounter
|
||||
import com.fsck.k9.message.extractors.MessageFulltextCreator
|
||||
import com.fsck.k9.message.extractors.MessagePreviewCreator
|
||||
import org.koin.dsl.module
|
||||
|
||||
val mailStoreModule = module {
|
||||
|
@ -14,10 +17,22 @@ val mailStoreModule = module {
|
|||
folderRepositoryManager = get(),
|
||||
localStoreProvider = get(),
|
||||
messageStoreManager = get(),
|
||||
specialFolderSelectionStrategy = get()
|
||||
specialFolderSelectionStrategy = get(),
|
||||
saveMessageDataCreator = get()
|
||||
)
|
||||
}
|
||||
factory { SpecialLocalFoldersCreator(preferences = get(), localStoreProvider = get()) }
|
||||
single { MessageStoreManager(accountManager = get(), messageStoreFactory = get()) }
|
||||
single { MessageRepository(messageStoreManager = get()) }
|
||||
factory { MessagePreviewCreator.newInstance() }
|
||||
factory { MessageFulltextCreator.newInstance() }
|
||||
factory { AttachmentCounter.newInstance() }
|
||||
factory {
|
||||
SaveMessageDataCreator(
|
||||
encryptionExtractor = get(),
|
||||
messagePreviewCreator = get(),
|
||||
messageFulltextCreator = get(),
|
||||
attachmentCounter = get()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,11 @@ import com.fsck.k9.mail.Header
|
|||
* storage implementation.
|
||||
*/
|
||||
interface MessageStore {
|
||||
/**
|
||||
* Save a remote message in this store.
|
||||
*/
|
||||
fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData)
|
||||
|
||||
/**
|
||||
* Move a message to another folder.
|
||||
*
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
package com.fsck.k9.mailstore
|
||||
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.message.extractors.PreviewResult
|
||||
|
||||
data class SaveMessageData(
|
||||
val message: Message,
|
||||
val subject: String?,
|
||||
val date: Long,
|
||||
val internalDate: Long,
|
||||
val partialMessage: Boolean,
|
||||
val attachmentCount: Int,
|
||||
val previewResult: PreviewResult,
|
||||
val textForSearchIndex: String? = null,
|
||||
val encryptionType: String?
|
||||
)
|
|
@ -0,0 +1,47 @@
|
|||
package com.fsck.k9.mailstore
|
||||
|
||||
import com.fsck.k9.crypto.EncryptionExtractor
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.message.extractors.AttachmentCounter
|
||||
import com.fsck.k9.message.extractors.MessageFulltextCreator
|
||||
import com.fsck.k9.message.extractors.MessagePreviewCreator
|
||||
|
||||
class SaveMessageDataCreator(
|
||||
private val encryptionExtractor: EncryptionExtractor,
|
||||
private val messagePreviewCreator: MessagePreviewCreator,
|
||||
private val messageFulltextCreator: MessageFulltextCreator,
|
||||
private val attachmentCounter: AttachmentCounter
|
||||
) {
|
||||
fun createSaveMessageData(message: Message, partialMessage: Boolean): SaveMessageData {
|
||||
val now = System.currentTimeMillis()
|
||||
val date = message.sentDate?.time ?: now
|
||||
val internalDate = message.internalDate?.time ?: now
|
||||
|
||||
val encryptionResult = encryptionExtractor.extractEncryption(message)
|
||||
return if (encryptionResult != null) {
|
||||
SaveMessageData(
|
||||
message = message,
|
||||
subject = message.subject,
|
||||
date = date,
|
||||
internalDate = internalDate,
|
||||
partialMessage = partialMessage,
|
||||
attachmentCount = encryptionResult.attachmentCount,
|
||||
previewResult = encryptionResult.previewResult,
|
||||
textForSearchIndex = encryptionResult.textForSearchIndex,
|
||||
encryptionType = encryptionResult.encryptionType
|
||||
)
|
||||
} else {
|
||||
SaveMessageData(
|
||||
message = message,
|
||||
subject = message.subject,
|
||||
date = date,
|
||||
internalDate = internalDate,
|
||||
partialMessage = partialMessage,
|
||||
attachmentCount = attachmentCounter.getAttachmentCount(message),
|
||||
previewResult = messagePreviewCreator.createPreview(message),
|
||||
textForSearchIndex = messageFulltextCreator.createFulltext(message),
|
||||
encryptionType = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package com.fsck.k9.message.extractors
|
||||
|
||||
import com.fsck.k9.mail.Part
|
||||
import com.fsck.k9.mail.internet.MimeParameterDecoder
|
||||
import com.fsck.k9.mail.internet.MimeUtility
|
||||
import com.fsck.k9.mail.internet.MimeValue
|
||||
import java.util.Locale
|
||||
|
||||
private const val FALLBACK_NAME = "noname"
|
||||
|
||||
/**
|
||||
* Extract a display name and the size from the headers of a message part.
|
||||
*/
|
||||
class BasicPartInfoExtractor {
|
||||
fun extractPartInfo(part: Part): BasicPartInfo {
|
||||
val contentDisposition = part.disposition?.toMimeValue()
|
||||
|
||||
return BasicPartInfo(
|
||||
displayName = part.getDisplayName(contentDisposition),
|
||||
size = contentDisposition?.getParameter("size")?.toLongOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
fun extractDisplayName(part: Part): String {
|
||||
return part.getDisplayName()
|
||||
}
|
||||
|
||||
private fun Part.getDisplayName(contentDisposition: MimeValue? = disposition?.toMimeValue()): String {
|
||||
return contentDisposition?.getParameter("filename")
|
||||
?: contentType?.getParameter("name")
|
||||
?: mimeType.toDisplayName()
|
||||
}
|
||||
|
||||
private fun String?.toDisplayName(): String {
|
||||
val extension = this?.let { mimeType -> MimeUtility.getExtensionByMimeType(mimeType) }
|
||||
return if (extension.isNullOrEmpty()) FALLBACK_NAME else "$FALLBACK_NAME.$extension"
|
||||
}
|
||||
|
||||
private fun String.toMimeValue(): MimeValue = MimeParameterDecoder.decode(this)
|
||||
|
||||
private fun MimeValue.getParameter(name: String): String? = parameters[name.toLowerCase(Locale.ROOT)]
|
||||
|
||||
private fun String.getParameter(name: String): String? {
|
||||
val mimeValue = MimeParameterDecoder.decode(this)
|
||||
return mimeValue.parameters[name.toLowerCase(Locale.ROOT)]
|
||||
}
|
||||
}
|
||||
|
||||
data class BasicPartInfo(
|
||||
val displayName: String,
|
||||
val size: Long?
|
||||
)
|
|
@ -5,4 +5,5 @@ import org.koin.dsl.module
|
|||
val extractorModule = module {
|
||||
single { AttachmentInfoExtractor(get()) }
|
||||
single { TextPartFinder() }
|
||||
single { BasicPartInfoExtractor() }
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ class K9BackendFolderTest : K9RobolectricTest() {
|
|||
val preferences: Preferences by inject()
|
||||
val localStoreProvider: LocalStoreProvider by inject()
|
||||
val messageStoreManager: MessageStoreManager by inject()
|
||||
val saveMessageDataCreator: SaveMessageDataCreator by inject()
|
||||
|
||||
val account: Account = createAccount()
|
||||
val backendFolder = createBackendFolder()
|
||||
|
@ -118,7 +119,13 @@ class K9BackendFolderTest : K9RobolectricTest() {
|
|||
fun createBackendFolder(): BackendFolder {
|
||||
val localStore: LocalStore = localStoreProvider.getInstance(account)
|
||||
val messageStore = messageStoreManager.getMessageStore(account)
|
||||
val backendStorage = K9BackendStorage(localStore, messageStore, createFolderSettingsProvider(), emptyList())
|
||||
val backendStorage = K9BackendStorage(
|
||||
localStore,
|
||||
messageStore,
|
||||
createFolderSettingsProvider(),
|
||||
saveMessageDataCreator,
|
||||
emptyList()
|
||||
)
|
||||
backendStorage.updateFolders {
|
||||
createFolders(listOf(FolderInfo(FOLDER_SERVER_ID, FOLDER_NAME, FOLDER_TYPE)))
|
||||
}
|
||||
|
@ -126,7 +133,7 @@ class K9BackendFolderTest : K9RobolectricTest() {
|
|||
val folderServerIds = backendStorage.getFolderServerIds()
|
||||
assertTrue(FOLDER_SERVER_ID in folderServerIds)
|
||||
|
||||
return K9BackendFolder(localStore, messageStore, FOLDER_SERVER_ID)
|
||||
return K9BackendFolder(localStore, messageStore, saveMessageDataCreator, FOLDER_SERVER_ID)
|
||||
}
|
||||
|
||||
fun createMessageInBackendFolder(messageServerId: String, flags: Set<Flag> = emptySet()) {
|
||||
|
|
|
@ -20,6 +20,7 @@ class K9BackendStorageTest : K9RobolectricTest() {
|
|||
val preferences: Preferences by inject()
|
||||
val localStoreProvider: LocalStoreProvider by inject()
|
||||
val messageStoreManager: MessageStoreManager by inject()
|
||||
val saveMessageDataCreator: SaveMessageDataCreator by inject()
|
||||
|
||||
val account: Account = createAccount()
|
||||
val database: LockableDatabase = localStoreProvider.getInstance(account).database
|
||||
|
@ -81,7 +82,7 @@ class K9BackendStorageTest : K9RobolectricTest() {
|
|||
val localStore = localStoreProvider.getInstance(account)
|
||||
val messageStore = messageStoreManager.getMessageStore(account)
|
||||
val folderSettingsProvider = createFolderSettingsProvider()
|
||||
return K9BackendStorage(localStore, messageStore, folderSettingsProvider, emptyList())
|
||||
return K9BackendStorage(localStore, messageStore, folderSettingsProvider, saveMessageDataCreator, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
package com.fsck.k9.message.extractors
|
||||
|
||||
import com.fsck.k9.mail.Part
|
||||
import com.fsck.k9.mail.internet.MimeBodyPart
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class BasicPartInfoExtractorTest {
|
||||
private val basicPartInfoExtractor = BasicPartInfoExtractor()
|
||||
|
||||
@Test
|
||||
fun `extractPartInfo with 'filename' parameter in Content-Disposition header`() {
|
||||
val part = createPart(
|
||||
contentType = "application/octet-stream",
|
||||
contentDisposition = "attachment; filename=\"attachment_name.txt\"; size=23"
|
||||
)
|
||||
|
||||
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
|
||||
|
||||
assertThat(partInfo.displayName).isEqualTo("attachment_name.txt")
|
||||
assertThat(partInfo.size).isEqualTo(23L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractPartInfo with 'name' parameter in Content-Type header`() {
|
||||
val part = createPart(
|
||||
contentType = "image/jpeg; name=\"attachment.jpeg\"",
|
||||
contentDisposition = "attachment; size=42"
|
||||
)
|
||||
|
||||
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
|
||||
|
||||
assertThat(partInfo.displayName).isEqualTo("attachment.jpeg")
|
||||
assertThat(partInfo.size).isEqualTo(42L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractPartInfo without display name and size`() {
|
||||
val part = createPart(contentType = "text/plain", contentDisposition = "attachment")
|
||||
|
||||
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
|
||||
|
||||
assertThat(partInfo.displayName).isEqualTo("noname.txt")
|
||||
assertThat(partInfo.size).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractPartInfo without display name and unknown mime type`() {
|
||||
val part = createPart(contentType = "x-made-up/unknown", contentDisposition = "attachment")
|
||||
|
||||
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
|
||||
|
||||
assertThat(partInfo.displayName).isEqualTo("noname")
|
||||
assertThat(partInfo.size).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractPartInfo with missing Content-Disposition header`() {
|
||||
val part = createPart(
|
||||
contentType = "application/octet-stream; name=\"attachment.dat\"",
|
||||
contentDisposition = null
|
||||
)
|
||||
|
||||
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
|
||||
|
||||
assertThat(partInfo.displayName).isEqualTo("attachment.dat")
|
||||
assertThat(partInfo.size).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractPartInfo with missing Content-Disposition header and name`() {
|
||||
val part = createPart(
|
||||
contentType = "application/octet-stream",
|
||||
contentDisposition = null
|
||||
)
|
||||
|
||||
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
|
||||
|
||||
assertThat(partInfo.displayName).isEqualTo("noname")
|
||||
assertThat(partInfo.size).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractPartInfo without any relevant headers`() {
|
||||
val part = createPart(
|
||||
contentType = null,
|
||||
contentDisposition = null
|
||||
)
|
||||
|
||||
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
|
||||
|
||||
assertThat(partInfo.displayName).isEqualTo("noname.txt")
|
||||
assertThat(partInfo.size).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractPartInfo with invalid Content-Disposition header`() {
|
||||
val part = createPart(
|
||||
contentType = "application/octet-stream",
|
||||
contentDisposition = "something; <invalid>"
|
||||
)
|
||||
|
||||
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
|
||||
|
||||
assertThat(partInfo.displayName).isEqualTo("noname")
|
||||
assertThat(partInfo.size).isNull()
|
||||
}
|
||||
|
||||
private fun createPart(contentType: String?, contentDisposition: String?): Part {
|
||||
return MimeBodyPart().apply {
|
||||
if (contentType != null) {
|
||||
addHeader("Content-Type", contentType)
|
||||
}
|
||||
|
||||
if (contentDisposition != null) {
|
||||
addHeader("Content-Disposition", contentDisposition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ dependencies {
|
|||
implementation "androidx.core:core-ktx:${versions.androidxCore}"
|
||||
implementation "com.jakewharton.timber:timber:${versions.timber}"
|
||||
implementation "org.apache.james:apache-mime4j-core:${versions.mime4j}"
|
||||
implementation "commons-io:commons-io:${versions.commonsIo}"
|
||||
implementation "com.squareup.moshi:moshi:${versions.moshi}"
|
||||
|
||||
testImplementation project(':mail:testing')
|
||||
|
|
|
@ -7,5 +7,7 @@ import org.koin.dsl.module
|
|||
|
||||
val storageModule = module {
|
||||
single<SchemaDefinitionFactory> { K9SchemaDefinitionFactory() }
|
||||
single<MessageStoreFactory> { K9MessageStoreFactory(localStoreProvider = get(), storageManager = get()) }
|
||||
single<MessageStoreFactory> {
|
||||
K9MessageStoreFactory(localStoreProvider = get(), storageManager = get(), basicPartInfoExtractor = get())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import com.fsck.k9.K9
|
||||
import com.fsck.k9.helper.FileHelper
|
||||
import com.fsck.k9.mailstore.StorageManager
|
||||
import com.fsck.k9.mailstore.StorageManager.InternalStorageProvider
|
||||
import java.io.File
|
||||
|
@ -11,14 +12,19 @@ class AttachmentFileManager(
|
|||
private val accountUuid: String
|
||||
) {
|
||||
fun deleteFile(messagePartId: Long) {
|
||||
val file = getAttachmentFile(messagePartId.toString())
|
||||
val file = getAttachmentFile(messagePartId)
|
||||
if (file.exists() && !file.delete() && K9.isDebugLoggingEnabled) {
|
||||
Timber.w("Couldn't delete message part file: %s", file.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAttachmentFile(messagePartId: String): File {
|
||||
fun moveTemporaryFile(temporaryFile: File, messagePartId: Long) {
|
||||
val destinationFile = getAttachmentFile(messagePartId)
|
||||
FileHelper.renameOrMoveByCopying(temporaryFile, destinationFile)
|
||||
}
|
||||
|
||||
private fun getAttachmentFile(messagePartId: Long): File {
|
||||
val attachmentDirectory = storageManager.getAttachmentDirectory(accountUuid, InternalStorageProvider.ID)
|
||||
return File(attachmentDirectory, messagePartId)
|
||||
return File(attachmentDirectory, messagePartId.toString())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import android.database.sqlite.SQLiteDatabase
|
|||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
|
||||
private val SPECIAL_FLAGS = setOf(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED)
|
||||
internal val SPECIAL_FLAGS = setOf(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED)
|
||||
|
||||
internal class FlagMessageOperations(private val lockableDatabase: LockableDatabase) {
|
||||
|
||||
|
|
|
@ -12,17 +12,26 @@ import com.fsck.k9.mailstore.LocalStore
|
|||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import com.fsck.k9.mailstore.MessageStore
|
||||
import com.fsck.k9.mailstore.MoreMessages
|
||||
import com.fsck.k9.mailstore.SaveMessageData
|
||||
import com.fsck.k9.mailstore.StorageManager
|
||||
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
|
||||
|
||||
// TODO: Remove dependency on LocalStore
|
||||
class K9MessageStore(
|
||||
private val localStore: LocalStore,
|
||||
storageManager: StorageManager,
|
||||
basicPartInfoExtractor: BasicPartInfoExtractor,
|
||||
accountUuid: String
|
||||
) : MessageStore {
|
||||
private val database: LockableDatabase = localStore.database
|
||||
private val attachmentFileManager = AttachmentFileManager(storageManager, accountUuid)
|
||||
private val threadMessageOperations = ThreadMessageOperations()
|
||||
private val saveMessageOperations = SaveMessageOperations(
|
||||
database,
|
||||
attachmentFileManager,
|
||||
basicPartInfoExtractor,
|
||||
threadMessageOperations
|
||||
)
|
||||
private val moveMessageOperations = MoveMessageOperations(database, threadMessageOperations)
|
||||
private val flagMessageOperations = FlagMessageOperations(database)
|
||||
private val retrieveMessageOperations = RetrieveMessageOperations(database)
|
||||
|
@ -33,6 +42,10 @@ class K9MessageStore(
|
|||
private val deleteFolderOperations = DeleteFolderOperations(database, attachmentFileManager)
|
||||
private val keyValueStoreOperations = KeyValueStoreOperations(database)
|
||||
|
||||
override fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData) {
|
||||
saveMessageOperations.saveRemoteMessage(folderId, messageServerId, messageData)
|
||||
}
|
||||
|
||||
override fun moveMessage(messageId: Long, destinationFolderId: Long): Long {
|
||||
return moveMessageOperations.moveMessage(messageId, destinationFolderId).also {
|
||||
localStore.notifyChange()
|
||||
|
|
|
@ -5,13 +5,15 @@ import com.fsck.k9.mailstore.LocalStoreProvider
|
|||
import com.fsck.k9.mailstore.MessageStore
|
||||
import com.fsck.k9.mailstore.MessageStoreFactory
|
||||
import com.fsck.k9.mailstore.StorageManager
|
||||
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
|
||||
|
||||
class K9MessageStoreFactory(
|
||||
private val localStoreProvider: LocalStoreProvider,
|
||||
private val storageManager: StorageManager
|
||||
private val storageManager: StorageManager,
|
||||
private val basicPartInfoExtractor: BasicPartInfoExtractor
|
||||
) : MessageStoreFactory {
|
||||
override fun create(account: Account): MessageStore {
|
||||
val localStore = localStoreProvider.getInstance(account)
|
||||
return K9MessageStore(localStore, storageManager, account.uuid)
|
||||
return K9MessageStore(localStore, storageManager, basicPartInfoExtractor, account.uuid)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,382 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fsck.k9.mail.Address
|
||||
import com.fsck.k9.mail.Body
|
||||
import com.fsck.k9.mail.BoundaryGenerator
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.Message.RecipientType
|
||||
import com.fsck.k9.mail.Multipart
|
||||
import com.fsck.k9.mail.Part
|
||||
import com.fsck.k9.mail.filter.CountingOutputStream
|
||||
import com.fsck.k9.mail.internet.BinaryTempFileBody
|
||||
import com.fsck.k9.mail.internet.MimeHeader
|
||||
import com.fsck.k9.mail.internet.MimeUtility
|
||||
import com.fsck.k9.mail.internet.SizeAware
|
||||
import com.fsck.k9.mailstore.DatabasePreviewType
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import com.fsck.k9.mailstore.SaveMessageData
|
||||
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
|
||||
import com.fsck.k9.message.extractors.PreviewResult.PreviewType
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Locale
|
||||
import java.util.Stack
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.james.mime4j.codec.Base64InputStream
|
||||
import org.apache.james.mime4j.codec.QuotedPrintableInputStream
|
||||
import org.apache.james.mime4j.util.MimeUtil
|
||||
|
||||
internal const val MAX_BODY_SIZE_FOR_DATABASE = 16 * 1024L
|
||||
|
||||
internal class SaveMessageOperations(
|
||||
private val lockableDatabase: LockableDatabase,
|
||||
private val attachmentFileManager: AttachmentFileManager,
|
||||
private val partInfoExtractor: BasicPartInfoExtractor,
|
||||
private val threadMessageOperations: ThreadMessageOperations
|
||||
) {
|
||||
fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData) {
|
||||
lockableDatabase.execute(true) { database ->
|
||||
val message = messageData.message
|
||||
|
||||
val threadInfo = threadMessageOperations.doMessageThreading(database, folderId, message.toThreadHeaders())
|
||||
|
||||
val rootMessagePartId = saveMessageParts(database, message)
|
||||
val messageId = saveMessage(
|
||||
database,
|
||||
folderId,
|
||||
messageServerId,
|
||||
rootMessagePartId,
|
||||
messageData,
|
||||
emptyMessageId = threadInfo.messageId
|
||||
)
|
||||
|
||||
if (threadInfo.threadId == null) {
|
||||
threadMessageOperations.createThreadEntry(database, messageId, threadInfo.rootId, threadInfo.parentId)
|
||||
}
|
||||
|
||||
createOrReplaceFulltextEntry(database, messageId, messageData)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveMessageParts(database: SQLiteDatabase, message: Message): Long {
|
||||
val rootPartContainer = PartContainer(parentId = null, part = message)
|
||||
val rootId = saveMessagePart(database, rootPartContainer, rootId = null, order = 0)
|
||||
|
||||
val partsToSave = Stack<PartContainer>()
|
||||
addChildrenToStack(partsToSave, part = message, parentId = rootId)
|
||||
|
||||
var order = 1
|
||||
while (partsToSave.isNotEmpty()) {
|
||||
val partContainer = partsToSave.pop()
|
||||
val messagePartId = saveMessagePart(database, partContainer, rootId, order)
|
||||
order++
|
||||
addChildrenToStack(partsToSave, partContainer.part, parentId = messagePartId)
|
||||
}
|
||||
|
||||
return rootId
|
||||
}
|
||||
|
||||
private fun saveMessagePart(
|
||||
database: SQLiteDatabase,
|
||||
partContainer: PartContainer,
|
||||
rootId: Long?,
|
||||
order: Int
|
||||
): Long {
|
||||
val part = partContainer.part
|
||||
val values = ContentValues().apply {
|
||||
put("root", rootId)
|
||||
put("parent", partContainer.parentId ?: -1) // -1 for compatibility with previous code
|
||||
put("seq", order)
|
||||
put("server_extra", part.serverExtra)
|
||||
}
|
||||
|
||||
return updateOrInsertMessagePart(database, values, part, existingMessagePartId = null)
|
||||
}
|
||||
|
||||
private fun updateOrInsertMessagePart(
|
||||
database: SQLiteDatabase,
|
||||
values: ContentValues,
|
||||
part: Part,
|
||||
existingMessagePartId: Long?
|
||||
): Long {
|
||||
val headerBytes = getHeaderBytes(part)
|
||||
values.put("mime_type", part.mimeType)
|
||||
values.put("header", headerBytes)
|
||||
values.put("type", MessagePartType.UNKNOWN)
|
||||
|
||||
val file: File? = when (val body = part.body) {
|
||||
is Multipart -> multipartToContentValues(values, body)
|
||||
is Message -> messageMarkerToContentValues(values)
|
||||
null -> missingPartToContentValues(values, part)
|
||||
else -> leafPartToContentValues(values, part, body)
|
||||
}
|
||||
|
||||
val messagePartId = if (existingMessagePartId != null) {
|
||||
database.update("message_parts", values, "id = ?", arrayOf(existingMessagePartId.toString()))
|
||||
existingMessagePartId
|
||||
} else {
|
||||
database.insertOrThrow("message_parts", null, values)
|
||||
}
|
||||
|
||||
if (file != null) {
|
||||
attachmentFileManager.moveTemporaryFile(file, messagePartId)
|
||||
}
|
||||
|
||||
return messagePartId
|
||||
}
|
||||
|
||||
private fun multipartToContentValues(values: ContentValues, multipart: Multipart): File? {
|
||||
values.put("data_location", DataLocation.CHILD_PART_CONTAINS_DATA)
|
||||
values.put("preamble", multipart.preamble)
|
||||
values.put("epilogue", multipart.epilogue)
|
||||
values.put("boundary", multipart.boundary)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun messageMarkerToContentValues(cv: ContentValues): File? {
|
||||
cv.put("data_location", DataLocation.CHILD_PART_CONTAINS_DATA)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun missingPartToContentValues(values: ContentValues, part: Part): File? {
|
||||
val partInfo = partInfoExtractor.extractPartInfo(part)
|
||||
values.put("display_name", partInfo.displayName)
|
||||
values.put("data_location", DataLocation.MISSING)
|
||||
values.put("decoded_body_size", partInfo.size)
|
||||
|
||||
if (MimeUtility.isMultipart(part.mimeType)) {
|
||||
values.put("boundary", BoundaryGenerator.getInstance().generateBoundary())
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun leafPartToContentValues(values: ContentValues, part: Part, body: Body): File? {
|
||||
val displayName = partInfoExtractor.extractDisplayName(part)
|
||||
values.put("display_name", displayName)
|
||||
|
||||
val encoding = getTransferEncoding(part)
|
||||
values.put("encoding", encoding)
|
||||
values.put("content_id", part.contentId)
|
||||
|
||||
check(body is SizeAware) { "Body needs to implement SizeAware" }
|
||||
val sizeAwareBody = body as SizeAware
|
||||
val fileSize = sizeAwareBody.size
|
||||
|
||||
return if (fileSize > MAX_BODY_SIZE_FOR_DATABASE) {
|
||||
values.put("data_location", DataLocation.ON_DISK)
|
||||
val file = writeBodyToDiskIfNecessary(part)
|
||||
val size = decodeAndCountBytes(file, encoding, fileSize)
|
||||
values.put("decoded_body_size", size)
|
||||
|
||||
file
|
||||
} else {
|
||||
values.put("data_location", DataLocation.IN_DATABASE)
|
||||
val bodyData = getBodyBytes(body)
|
||||
values.put("data", bodyData)
|
||||
val size = decodeAndCountBytes(bodyData.inputStream(), encoding, bodyData.size.toLong())
|
||||
values.put("decoded_body_size", size)
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeBodyToDiskIfNecessary(part: Part): File? {
|
||||
val body = part.body
|
||||
return if (body is BinaryTempFileBody) {
|
||||
body.file
|
||||
} else {
|
||||
writeBodyToDisk(body)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeBodyToDisk(body: Body): File? {
|
||||
val file = File.createTempFile("body", null, BinaryTempFileBody.getTempDirectory())
|
||||
FileOutputStream(file).use { outputStream ->
|
||||
body.writeTo(outputStream)
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
private fun decodeAndCountBytes(file: File?, encoding: String, fallbackValue: Long): Long {
|
||||
return FileInputStream(file).use { inputStream ->
|
||||
decodeAndCountBytes(inputStream, encoding, fallbackValue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAndCountBytes(rawInputStream: InputStream, encoding: String, fallbackValue: Long): Long {
|
||||
return try {
|
||||
getDecodingInputStream(rawInputStream, encoding).use { decodingInputStream ->
|
||||
val countingOutputStream = CountingOutputStream()
|
||||
IOUtils.copy(decodingInputStream, countingOutputStream)
|
||||
countingOutputStream.count
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
fallbackValue
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDecodingInputStream(rawInputStream: InputStream, encoding: String?): InputStream {
|
||||
return when (encoding) {
|
||||
MimeUtil.ENC_BASE64 -> {
|
||||
object : Base64InputStream(rawInputStream) {
|
||||
override fun close() {
|
||||
super.close()
|
||||
rawInputStream.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
MimeUtil.ENC_QUOTED_PRINTABLE -> {
|
||||
object : QuotedPrintableInputStream(rawInputStream) {
|
||||
override fun close() {
|
||||
super.close()
|
||||
rawInputStream.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
rawInputStream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getHeaderBytes(part: Part): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
part.writeHeaderTo(output)
|
||||
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
private fun getBodyBytes(body: Body): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
body.writeTo(output)
|
||||
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
private fun getTransferEncoding(part: Part): String {
|
||||
val contentTransferEncoding = part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING).firstOrNull()
|
||||
return contentTransferEncoding?.toLowerCase(Locale.ROOT) ?: MimeUtil.ENC_7BIT
|
||||
}
|
||||
|
||||
private fun addChildrenToStack(stack: Stack<PartContainer>, part: Part, parentId: Long) {
|
||||
when (val body = part.body) {
|
||||
is Multipart -> {
|
||||
for (i in body.count - 1 downTo 0) {
|
||||
val childPart = body.getBodyPart(i)
|
||||
stack.push(PartContainer(parentId, childPart))
|
||||
}
|
||||
}
|
||||
is Message -> {
|
||||
stack.push(PartContainer(parentId, body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveMessage(
|
||||
database: SQLiteDatabase,
|
||||
folderId: Long,
|
||||
messageServerId: String,
|
||||
rootMessagePartId: Long,
|
||||
messageData: SaveMessageData,
|
||||
emptyMessageId: Long?
|
||||
): Long {
|
||||
val message = messageData.message
|
||||
|
||||
if (messageData.partialMessage) {
|
||||
message.setFlag(Flag.X_DOWNLOADED_PARTIAL, true)
|
||||
} else {
|
||||
message.setFlag(Flag.X_DOWNLOADED_FULL, true)
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put("folder_id", folderId)
|
||||
put("uid", messageServerId)
|
||||
put("deleted", 0)
|
||||
put("empty", 0)
|
||||
put("message_part_id", rootMessagePartId)
|
||||
put("date", messageData.date)
|
||||
put("internal_date", messageData.internalDate)
|
||||
put("subject", messageData.subject)
|
||||
put("flags", message.flags.toDatabaseValue())
|
||||
put("read", message.isSet(Flag.SEEN).toDatabaseValue())
|
||||
put("flagged", message.isSet(Flag.FLAGGED).toDatabaseValue())
|
||||
put("answered", message.isSet(Flag.ANSWERED).toDatabaseValue())
|
||||
put("forwarded", message.isSet(Flag.FORWARDED).toDatabaseValue())
|
||||
put("sender_list", Address.pack(message.from))
|
||||
put("to_list", Address.pack(message.getRecipients(RecipientType.TO)))
|
||||
put("cc_list", Address.pack(message.getRecipients(RecipientType.CC)))
|
||||
put("bcc_list", Address.pack(message.getRecipients(RecipientType.BCC)))
|
||||
put("reply_to_list", Address.pack(message.replyTo))
|
||||
put("attachment_count", messageData.attachmentCount)
|
||||
put("message_id", message.messageId)
|
||||
put("mime_type", message.mimeType)
|
||||
put("encryption_type", messageData.encryptionType)
|
||||
|
||||
val previewResult = messageData.previewResult
|
||||
put("preview_type", previewResult.previewType.toDatabaseValue())
|
||||
if (previewResult.isPreviewTextAvailable) {
|
||||
put("preview", previewResult.previewText)
|
||||
} else {
|
||||
putNull("preview")
|
||||
}
|
||||
}
|
||||
|
||||
return if (emptyMessageId != null) {
|
||||
values.put("id", emptyMessageId)
|
||||
database.replace("messages", null, values)
|
||||
emptyMessageId
|
||||
} else {
|
||||
database.insert("messages", null, values)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOrReplaceFulltextEntry(database: SQLiteDatabase, messageId: Long, messageData: SaveMessageData) {
|
||||
val fulltext = messageData.textForSearchIndex ?: return
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put("docid", messageId)
|
||||
put("fulltext", fulltext)
|
||||
}
|
||||
|
||||
database.replace("messages_fulltext", null, values)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Set<Flag>.toDatabaseValue(): String {
|
||||
return this
|
||||
.filter { it !in SPECIAL_FLAGS }
|
||||
.joinToString(separator = ",")
|
||||
}
|
||||
|
||||
private fun Boolean.toDatabaseValue() = if (this) 1 else 0
|
||||
|
||||
private fun PreviewType.toDatabaseValue(): String {
|
||||
return DatabasePreviewType.fromPreviewType(this).databaseValue
|
||||
}
|
||||
|
||||
// Note: The contents of the 'message_parts' table depend on these values.
|
||||
// TODO: currently unused, might be used for caching at a later point
|
||||
internal object MessagePartType {
|
||||
const val UNKNOWN = 0
|
||||
}
|
||||
|
||||
// Note: The contents of the 'message_parts' table depend on these values.
|
||||
internal object DataLocation {
|
||||
const val MISSING = 0
|
||||
const val IN_DATABASE = 1
|
||||
const val ON_DISK = 2
|
||||
const val CHILD_PART_CONTAINS_DATA = 3
|
||||
}
|
||||
|
||||
private data class PartContainer(val parentId: Long?, val part: Part)
|
|
@ -3,6 +3,7 @@ package com.fsck.k9.storage.messages
|
|||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fsck.k9.helper.Utility
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.message.MessageHeaderParser
|
||||
import java.util.Locale
|
||||
|
||||
|
@ -59,7 +60,7 @@ internal class ThreadMessageOperations {
|
|||
}
|
||||
}
|
||||
|
||||
private fun createThreadEntry(database: SQLiteDatabase, messageId: Long, rootId: Long?, parentId: Long?): Long {
|
||||
fun createThreadEntry(database: SQLiteDatabase, messageId: Long, rootId: Long?, parentId: Long?): Long {
|
||||
val values = ContentValues().apply {
|
||||
put("message_id", messageId)
|
||||
put("root", rootId)
|
||||
|
@ -70,7 +71,7 @@ internal class ThreadMessageOperations {
|
|||
}
|
||||
|
||||
// TODO: Use MessageIdParser
|
||||
private fun doMessageThreading(database: SQLiteDatabase, folderId: Long, threadHeaders: ThreadHeaders): ThreadInfo {
|
||||
fun doMessageThreading(database: SQLiteDatabase, folderId: Long, threadHeaders: ThreadHeaders): ThreadInfo {
|
||||
val messageIdHeader = threadHeaders.messageIdHeader
|
||||
val msgThreadInfo = getThreadInfo(database, folderId, messageIdHeader, onlyEmpty = true)
|
||||
|
||||
|
@ -201,3 +202,11 @@ internal data class ThreadHeaders(
|
|||
val inReplyToHeader: String?,
|
||||
val referencesHeader: String?
|
||||
)
|
||||
|
||||
internal fun Message.toThreadHeaders(): ThreadHeaders {
|
||||
return ThreadHeaders(
|
||||
messageIdHeader = messageId,
|
||||
inReplyToHeader = getHeader("In-Reply-To").firstOrNull(),
|
||||
referencesHeader = getHeader("References").firstOrNull()
|
||||
)
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ fun createDatabase(): SQLiteDatabase {
|
|||
fun SQLiteDatabase.createMessage(
|
||||
deleted: Boolean = false,
|
||||
folderId: Long,
|
||||
uid: String,
|
||||
uid: String? = null,
|
||||
subject: String = "",
|
||||
date: Long = 0L,
|
||||
flags: String = "",
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fsck.k9.helper.getIntOrNull
|
||||
import com.fsck.k9.helper.getLongOrNull
|
||||
import com.fsck.k9.helper.getStringOrNull
|
||||
import com.fsck.k9.helper.map
|
||||
|
||||
fun SQLiteDatabase.createMessagePart(
|
||||
type: Int = MessagePartType.UNKNOWN,
|
||||
root: Int? = null,
|
||||
parent: Int = -1,
|
||||
seq: Int = 0,
|
||||
mimeType: String? = null,
|
||||
decodedBodySize: Int? = null,
|
||||
displayName: String? = null,
|
||||
header: String? = null,
|
||||
encoding: String? = null,
|
||||
charset: String? = null,
|
||||
dataLocation: Int = DataLocation.MISSING,
|
||||
data: ByteArray? = null,
|
||||
preamble: String? = null,
|
||||
epilogue: String? = null,
|
||||
boundary: String? = null,
|
||||
contentId: String? = null,
|
||||
serverExtra: String? = null
|
||||
): Long {
|
||||
val values = ContentValues().apply {
|
||||
put("type", type)
|
||||
put("root", root)
|
||||
put("parent", parent)
|
||||
put("seq", seq)
|
||||
put("mime_type", mimeType)
|
||||
put("decoded_body_size", decodedBodySize)
|
||||
put("display_name", displayName)
|
||||
put("header", header)
|
||||
put("encoding", encoding)
|
||||
put("charset", charset)
|
||||
put("data_location", dataLocation)
|
||||
put("data", data)
|
||||
put("preamble", preamble)
|
||||
put("epilogue", epilogue)
|
||||
put("boundary", boundary)
|
||||
put("content_id", contentId)
|
||||
put("server_extra", serverExtra)
|
||||
}
|
||||
|
||||
return insert("message_parts", null, values)
|
||||
}
|
||||
|
||||
fun SQLiteDatabase.readMessageParts(): List<MessagePartEntry> {
|
||||
return rawQuery("SELECT * FROM message_parts", null).use { cursor ->
|
||||
cursor.map {
|
||||
MessagePartEntry(
|
||||
id = cursor.getLongOrNull("id"),
|
||||
type = cursor.getIntOrNull("type"),
|
||||
root = cursor.getLongOrNull("root"),
|
||||
parent = cursor.getLongOrNull("parent"),
|
||||
seq = cursor.getIntOrNull("seq"),
|
||||
mimeType = cursor.getStringOrNull("mime_type"),
|
||||
decodedBodySize = cursor.getIntOrNull("decoded_body_size"),
|
||||
displayName = cursor.getStringOrNull("display_name"),
|
||||
header = cursor.getBlobOrNull("header"),
|
||||
encoding = cursor.getStringOrNull("encoding"),
|
||||
charset = cursor.getStringOrNull("charset"),
|
||||
dataLocation = cursor.getIntOrNull("data_location"),
|
||||
data = cursor.getBlobOrNull("data"),
|
||||
preamble = cursor.getStringOrNull("preamble"),
|
||||
epilogue = cursor.getStringOrNull("epilogue"),
|
||||
boundary = cursor.getStringOrNull("boundary"),
|
||||
contentId = cursor.getStringOrNull("content_id"),
|
||||
serverExtra = cursor.getStringOrNull("server_extra"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MessagePartEntry(
|
||||
val id: Long?,
|
||||
val type: Int?,
|
||||
val root: Long?,
|
||||
val parent: Long?,
|
||||
val seq: Int?,
|
||||
val mimeType: String?,
|
||||
val decodedBodySize: Int?,
|
||||
val displayName: String?,
|
||||
val header: ByteArray?,
|
||||
val encoding: String?,
|
||||
val charset: String?,
|
||||
val dataLocation: Int?,
|
||||
val data: ByteArray?,
|
||||
val preamble: String?,
|
||||
val epilogue: String?,
|
||||
val boundary: String?,
|
||||
val contentId: String?,
|
||||
val serverExtra: String?
|
||||
)
|
||||
|
||||
private fun Cursor.getBlobOrNull(columnName: String): ByteArray? {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return if (isNull(columnIndex)) null else getBlob(columnIndex)
|
||||
}
|
|
@ -0,0 +1,355 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import com.fsck.k9.mail.Address
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.Multipart
|
||||
import com.fsck.k9.mail.Part
|
||||
import com.fsck.k9.mail.buildMessage
|
||||
import com.fsck.k9.mailstore.SaveMessageData
|
||||
import com.fsck.k9.mailstore.StorageManager
|
||||
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
|
||||
import com.fsck.k9.message.extractors.PreviewResult
|
||||
import com.fsck.k9.storage.RobolectricTest
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.nhaarman.mockitokotlin2.anyOrNull
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.eq
|
||||
import com.nhaarman.mockitokotlin2.mock
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.Stack
|
||||
import org.junit.After
|
||||
import org.junit.Test
|
||||
|
||||
private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000"
|
||||
|
||||
class SaveMessageOperationsTest : 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 basicPartInfoExtractor = BasicPartInfoExtractor()
|
||||
private val threadMessageOperations = ThreadMessageOperations()
|
||||
private val saveMessageOperations = SaveMessageOperations(
|
||||
lockableDatabase,
|
||||
attachmentFileManager,
|
||||
basicPartInfoExtractor,
|
||||
threadMessageOperations
|
||||
)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
messagePartDirectory.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save message with text_plain body`() {
|
||||
val messageData = buildMessage {
|
||||
header("Subject", "Test Message")
|
||||
header("From", "alice@domain.example")
|
||||
header("To", "Bob <bob@domain.example>")
|
||||
header("Cc", "<cloe@domain.example>")
|
||||
header("Date", "Mon, 12 Apr 2021 03:42:00 +0200")
|
||||
header("Message-ID", "<msg0001@domain.example>")
|
||||
|
||||
textBody("Text")
|
||||
}.apply {
|
||||
setFlag(Flag.FLAGGED, true)
|
||||
setFlag(Flag.SEEN, true)
|
||||
setFlag(Flag.ANSWERED, true)
|
||||
setFlag(Flag.FORWARDED, true)
|
||||
}.toSaveMessageData(
|
||||
previewResult = PreviewResult.text("Preview")
|
||||
)
|
||||
|
||||
saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData)
|
||||
|
||||
val messages = sqliteDatabase.readMessages()
|
||||
assertThat(messages).hasSize(1)
|
||||
val message = messages.first()
|
||||
with(message) {
|
||||
assertThat(deleted).isEqualTo(0)
|
||||
assertThat(folderId).isEqualTo(1)
|
||||
assertThat(uid).isEqualTo("uid1")
|
||||
assertThat(subject).isEqualTo("Test Message")
|
||||
assertThat(date).isEqualTo(1618191720000L)
|
||||
assertThat(internalDate).isEqualTo(1618191720000L)
|
||||
assertThat(flags).isEqualTo("X_DOWNLOADED_FULL")
|
||||
assertThat(senderList).isEqualTo("alice@domain.example")
|
||||
assertThat(toList).isEqualTo(Address.pack(Address.parse("Bob <bob@domain.example>")))
|
||||
assertThat(ccList).isEqualTo("cloe@domain.example")
|
||||
assertThat(bccList).isEqualTo("")
|
||||
assertThat(replyToList).isEqualTo("")
|
||||
assertThat(attachmentCount).isEqualTo(0)
|
||||
assertThat(messageId).isEqualTo("<msg0001@domain.example>")
|
||||
assertThat(previewType).isEqualTo("text")
|
||||
assertThat(preview).isEqualTo("Preview")
|
||||
assertThat(mimeType).isEqualTo("text/plain")
|
||||
assertThat(empty).isEqualTo(0)
|
||||
assertThat(read).isEqualTo(1)
|
||||
assertThat(flagged).isEqualTo(1)
|
||||
assertThat(answered).isEqualTo(1)
|
||||
assertThat(forwarded).isEqualTo(1)
|
||||
assertThat(encryptionType).isNull()
|
||||
}
|
||||
|
||||
val messageParts = sqliteDatabase.readMessageParts()
|
||||
assertThat(messageParts).hasSize(1)
|
||||
val messagePart = messageParts.first()
|
||||
with(messagePart) {
|
||||
assertThat(type).isEqualTo(MessagePartType.UNKNOWN)
|
||||
assertThat(root).isEqualTo(messagePart.id)
|
||||
assertThat(parent).isEqualTo(-1)
|
||||
assertThat(seq).isEqualTo(0)
|
||||
assertThat(mimeType).isEqualTo("text/plain")
|
||||
assertThat(displayName).isEqualTo("noname.txt")
|
||||
assertThat(header?.toString(Charsets.UTF_8)).isEqualTo(messageData.message.header())
|
||||
assertThat(encoding).isEqualTo("quoted-printable")
|
||||
assertThat(charset).isNull()
|
||||
assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE)
|
||||
assertThat(decodedBodySize).isEqualTo(4)
|
||||
assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("Text")
|
||||
assertThat(preamble).isNull()
|
||||
assertThat(epilogue).isNull()
|
||||
assertThat(boundary).isNull()
|
||||
assertThat(contentId).isNull()
|
||||
assertThat(serverExtra).isNull()
|
||||
}
|
||||
|
||||
val threads = sqliteDatabase.readThreads()
|
||||
assertThat(threads).hasSize(1)
|
||||
val thread = threads.first()
|
||||
with(thread) {
|
||||
assertThat(messageId).isEqualTo(message.id)
|
||||
assertThat(root).isEqualTo(id)
|
||||
assertThat(parent).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save message with multipart body`() {
|
||||
val messageData = buildMessage {
|
||||
multipart("alternative") {
|
||||
bodyPart("text/plain") {
|
||||
textBody("plain")
|
||||
}
|
||||
bodyPart("text/html") {
|
||||
textBody("html")
|
||||
}
|
||||
}
|
||||
}.toSaveMessageData()
|
||||
|
||||
saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData)
|
||||
|
||||
val messages = sqliteDatabase.readMessages()
|
||||
assertThat(messages).hasSize(1)
|
||||
|
||||
val messageParts = sqliteDatabase.readMessageParts()
|
||||
assertThat(messageParts).hasSize(3)
|
||||
|
||||
val rootMessagePart = messageParts.first { it.seq == 0 }
|
||||
with(rootMessagePart) {
|
||||
assertThat(type).isEqualTo(MessagePartType.UNKNOWN)
|
||||
assertThat(root).isEqualTo(id)
|
||||
assertThat(parent).isEqualTo(-1)
|
||||
assertThat(mimeType).isEqualTo("multipart/alternative")
|
||||
assertThat(displayName).isNull()
|
||||
assertThat(header?.toString(Charsets.UTF_8)).isEqualTo(messageData.message.header())
|
||||
assertThat(encoding).isNull()
|
||||
assertThat(charset).isNull()
|
||||
assertThat(dataLocation).isEqualTo(DataLocation.CHILD_PART_CONTAINS_DATA)
|
||||
assertThat(decodedBodySize).isNull()
|
||||
assertThat(data).isNull()
|
||||
assertThat(preamble).isNull()
|
||||
assertThat(epilogue).isNull()
|
||||
assertThat(boundary).isEqualTo(messageData.message.boundary())
|
||||
assertThat(contentId).isNull()
|
||||
assertThat(serverExtra).isNull()
|
||||
}
|
||||
|
||||
val textPlainMessagePart = messageParts.first { it.seq == 1 }
|
||||
with(textPlainMessagePart) {
|
||||
assertThat(type).isEqualTo(MessagePartType.UNKNOWN)
|
||||
assertThat(root).isEqualTo(rootMessagePart.id)
|
||||
assertThat(parent).isEqualTo(rootMessagePart.id)
|
||||
assertThat(mimeType).isEqualTo("text/plain")
|
||||
assertThat(displayName).isEqualTo("noname.txt")
|
||||
assertThat(header).isNotNull()
|
||||
assertThat(encoding).isEqualTo("quoted-printable")
|
||||
assertThat(charset).isNull()
|
||||
assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE)
|
||||
assertThat(decodedBodySize).isEqualTo(5)
|
||||
assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("plain")
|
||||
assertThat(preamble).isNull()
|
||||
assertThat(epilogue).isNull()
|
||||
assertThat(boundary).isNull()
|
||||
assertThat(contentId).isNull()
|
||||
assertThat(serverExtra).isNull()
|
||||
}
|
||||
|
||||
val textHtmlMessagePart = messageParts.first { it.seq == 2 }
|
||||
with(textHtmlMessagePart) {
|
||||
assertThat(type).isEqualTo(MessagePartType.UNKNOWN)
|
||||
assertThat(root).isEqualTo(rootMessagePart.id)
|
||||
assertThat(parent).isEqualTo(rootMessagePart.id)
|
||||
assertThat(mimeType).isEqualTo("text/html")
|
||||
assertThat(displayName).isEqualTo("noname.html")
|
||||
assertThat(header).isNotNull()
|
||||
assertThat(encoding).isEqualTo("quoted-printable")
|
||||
assertThat(charset).isNull()
|
||||
assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE)
|
||||
assertThat(decodedBodySize).isEqualTo(4)
|
||||
assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("html")
|
||||
assertThat(preamble).isNull()
|
||||
assertThat(epilogue).isNull()
|
||||
assertThat(boundary).isNull()
|
||||
assertThat(contentId).isNull()
|
||||
assertThat(serverExtra).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save message into existing thread`() {
|
||||
val messageId1 = sqliteDatabase.createMessage(
|
||||
folderId = 1,
|
||||
empty = true,
|
||||
messageIdHeader = "<msg0001@domain.example>"
|
||||
)
|
||||
val messageId2 = sqliteDatabase.createMessage(
|
||||
folderId = 1,
|
||||
empty = true,
|
||||
messageIdHeader = "<msg0002@domain.example>"
|
||||
)
|
||||
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 messageData = buildMessage {
|
||||
header("Message-ID", "<msg0002@domain.example>")
|
||||
header("In-Reply-To", "<msg0001@domain.example>")
|
||||
|
||||
textBody()
|
||||
}.toSaveMessageData()
|
||||
|
||||
saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData)
|
||||
|
||||
val threads = sqliteDatabase.readThreads()
|
||||
assertThat(threads).hasSize(3)
|
||||
|
||||
assertThat(threads.first { it.id == threadId1 }).isEqualTo(
|
||||
ThreadEntry(
|
||||
id = threadId1,
|
||||
messageId = messageId1,
|
||||
root = threadId1,
|
||||
parent = null
|
||||
)
|
||||
)
|
||||
|
||||
assertThat(threads.first { it.id == threadId2 }).isEqualTo(
|
||||
ThreadEntry(
|
||||
id = threadId2,
|
||||
messageId = messageId2,
|
||||
root = threadId1,
|
||||
parent = threadId1
|
||||
)
|
||||
)
|
||||
|
||||
assertThat(threads.first { it.id == threadId3 }).isEqualTo(
|
||||
ThreadEntry(
|
||||
id = threadId3,
|
||||
messageId = messageId3,
|
||||
root = threadId1,
|
||||
parent = threadId2
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save message with references header should create empty messages`() {
|
||||
val messageData = buildMessage {
|
||||
header("Message-ID", "<msg0003@domain.example>")
|
||||
header("In-Reply-To", "<msg0002@domain.example>")
|
||||
header("References", "<msg0001@domain.example> <msg0002@domain.example>")
|
||||
|
||||
textBody()
|
||||
}.toSaveMessageData()
|
||||
|
||||
saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData)
|
||||
|
||||
val messages = sqliteDatabase.readMessages()
|
||||
assertThat(messages).hasSize(3)
|
||||
|
||||
val threads = sqliteDatabase.readThreads()
|
||||
assertThat(threads).hasSize(3)
|
||||
|
||||
val thread1 = threads.first { it.id == it.root }
|
||||
val message1 = messages.first { it.id == thread1.messageId }
|
||||
assertThat(message1.empty).isEqualTo(1)
|
||||
|
||||
val thread2 = threads.first { it.parent == thread1.id }
|
||||
val message2 = messages.first { it.id == thread2.messageId }
|
||||
assertThat(message2.empty).isEqualTo(1)
|
||||
|
||||
val thread3 = threads.first { it.parent == thread2.id }
|
||||
val message3 = messages.first { it.id == thread3.messageId }
|
||||
assertThat(message3.empty).isEqualTo(0)
|
||||
assertThat(message3.uid).isEqualTo("uid1")
|
||||
}
|
||||
|
||||
private fun Message.toSaveMessageData(
|
||||
subject: String? = getSubject(),
|
||||
date: Long = sentDate?.time ?: System.currentTimeMillis(),
|
||||
internalDate: Long = date,
|
||||
partialMessage: Boolean = isPartialMessage(),
|
||||
attachmentCount: Int = 0,
|
||||
previewResult: PreviewResult = PreviewResult.none(),
|
||||
textForSearchIndex: String? = null,
|
||||
encryptionType: String? = null
|
||||
): SaveMessageData {
|
||||
return SaveMessageData(
|
||||
message = this,
|
||||
subject,
|
||||
date,
|
||||
internalDate,
|
||||
partialMessage,
|
||||
attachmentCount,
|
||||
previewResult,
|
||||
textForSearchIndex,
|
||||
encryptionType
|
||||
)
|
||||
}
|
||||
|
||||
private fun Message.isPartialMessage(): Boolean {
|
||||
val stack = Stack<Part>()
|
||||
stack.push(this)
|
||||
|
||||
while (stack.isNotEmpty()) {
|
||||
val part = stack.pop()
|
||||
when (val body = part.body) {
|
||||
null -> return true
|
||||
is Multipart -> {
|
||||
for (i in 0 until body.count) {
|
||||
stack.push(body.getBodyPart(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun Message.header(): String {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
writeHeaderTo(outputStream)
|
||||
return outputStream.toString("UTF-8")
|
||||
}
|
||||
|
||||
private fun Message.boundary(): String? = (body as Multipart).boundary
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
package com.fsck.k9.mail
|
||||
|
||||
import com.fsck.k9.mail.internet.MimeBodyPart
|
||||
import com.fsck.k9.mail.internet.MimeMessage
|
||||
import com.fsck.k9.mail.internet.MimeMessageHelper
|
||||
import com.fsck.k9.mail.internet.MimeMultipart
|
||||
import com.fsck.k9.mail.internet.TextBody
|
||||
import com.fsck.k9.mailstore.BinaryMemoryBody
|
||||
|
||||
fun buildMessage(block: PartBuilder.() -> Unit): Message {
|
||||
return MimeMessage().also { message ->
|
||||
PartBuilder(message).block()
|
||||
}
|
||||
}
|
||||
|
||||
@DslMarker
|
||||
annotation class MessageBuilderMarker
|
||||
|
||||
@MessageBuilderMarker
|
||||
class PartBuilder(private val part: Part) {
|
||||
private var gotBodyBlock = false
|
||||
|
||||
fun header(name: String, value: String) {
|
||||
part.addHeader(name, value)
|
||||
}
|
||||
|
||||
fun textBody(text: String = "Hello World") {
|
||||
require(!gotBodyBlock) { "Only one body block allowed" }
|
||||
gotBodyBlock = true
|
||||
|
||||
val body = TextBody(text)
|
||||
MimeMessageHelper.setBody(part, body)
|
||||
}
|
||||
|
||||
fun dataBody(size: Int = 20 * 1024, encoding: String = "7bit") {
|
||||
require(!gotBodyBlock) { "Only one body block allowed" }
|
||||
gotBodyBlock = true
|
||||
|
||||
val body = BinaryMemoryBody(ByteArray(size) { 'A'.toByte() }, encoding)
|
||||
MimeMessageHelper.setBody(part, body)
|
||||
}
|
||||
|
||||
fun multipart(subType: String = "mixed", block: MultipartBuilder.() -> Unit) {
|
||||
require(!gotBodyBlock) { "Only one body block allowed" }
|
||||
gotBodyBlock = true
|
||||
|
||||
val multipart = MimeMultipart.newInstance()
|
||||
multipart.setSubType(subType)
|
||||
MultipartBuilder(multipart).block()
|
||||
MimeMessageHelper.setBody(part, multipart)
|
||||
}
|
||||
}
|
||||
|
||||
@MessageBuilderMarker
|
||||
class MultipartBuilder(private val multipart: Multipart) {
|
||||
fun bodyPart(mimeType: String? = null, block: PartBuilder.() -> Unit) {
|
||||
MimeBodyPart().let { bodyPart ->
|
||||
if (mimeType != null) {
|
||||
bodyPart.addHeader("Content-Type", mimeType)
|
||||
}
|
||||
multipart.addBodyPart(bodyPart)
|
||||
PartBuilder(bodyPart).block()
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue