summaryrefslogtreecommitdiff
path: root/app/storage/src/test/java/com
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /app/storage/src/test/java/com
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'app/storage/src/test/java/com')
-rw-r--r--app/storage/src/test/java/com/fsck/k9/preferences/StorageEditorTest.kt207
-rw-r--r--app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt120
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/K9RobolectricTest.kt16
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/RobolectricTest.kt15
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java424
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt35
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/CheckFolderOperationsTest.kt65
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperationsTest.kt114
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/CopyMessageOperationsTest.kt252
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/CreateFolderOperationsTest.kt91
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteFolderOperationsTest.kt62
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteMessageOperationsTest.kt196
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/FileHelpers.kt10
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/FlagMessageOperationsTest.kt100
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/FolderHelpers.kt95
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueHelpers.kt77
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueStoreOperationsTest.kt210
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt213
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/MessagePartDatabaseHelpers.kt182
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/MoveMessageOperationsTest.kt216
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt522
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt491
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt222
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/SaveMessageOperationsTest.kt556
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadDatabaseHelpers.kt40
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadMessageOperationsTest.kt266
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt167
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateMessageOperationsTest.kt54
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/notifications/K9NotificationStoreTest.kt107
-rw-r--r--app/storage/src/test/java/com/fsck/k9/storage/notifications/NotificationsTableHelpers.kt40
30 files changed, 5165 insertions, 0 deletions
diff --git a/app/storage/src/test/java/com/fsck/k9/preferences/StorageEditorTest.kt b/app/storage/src/test/java/com/fsck/k9/preferences/StorageEditorTest.kt
new file mode 100644
index 0000000..df3cc70
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/preferences/StorageEditorTest.kt
@@ -0,0 +1,207 @@
+package com.fsck.k9.preferences
+
+import assertk.assertThat
+import assertk.assertions.isEmpty
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFalse
+import assertk.assertions.isTrue
+import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperationCallback
+import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperations
+import com.fsck.k9.storage.K9RobolectricTest
+import org.junit.Test
+import org.mockito.ArgumentMatchers.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doThrow
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stubbing
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.verifyNoMoreInteractions
+
+class StorageEditorTest : K9RobolectricTest() {
+ private val storage: Storage = Storage(mapOf("storage-key" to "storage-value"))
+ private val storageUpdater = TestStorageUpdater(storage)
+ private val storagePersister = mock<K9StoragePersister>()
+ private val storagePersisterOps = mock<StoragePersistOperations>()
+ private val editor = K9StorageEditor(storageUpdater, storagePersister)
+
+ private val workingMap = mutableMapOf<String, String>()
+
+ private val newValues: Map<String, String>
+ get() = storageUpdater.newStorage!!.all
+
+ @Test
+ fun commit_exception() {
+ stubbing(storagePersister) {
+ on { doInTransaction(any()) } doThrow RuntimeException()
+ }
+
+ val success = editor.commit()
+
+ assertThat(success).isFalse()
+ }
+
+ @Test
+ fun commit_trivial() {
+ prepareStoragePersisterMock()
+
+ val success = editor.commit()
+
+ assertThat(success).isTrue()
+ assertThat(newValues).isEqualTo(storage.all)
+
+ verifyNoMoreInteractions(storagePersisterOps)
+ }
+
+ @Test
+ fun putBoolean() {
+ prepareStoragePersisterMock()
+
+ editor.putBoolean("x", true)
+ val success = editor.commit()
+
+ assertThat(success).isTrue()
+ assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "true"))
+ verify(storagePersisterOps).put("x", "true")
+ verifyNoMoreInteractions(storagePersisterOps)
+ }
+
+ @Test
+ fun putInt() {
+ prepareStoragePersisterMock()
+
+ editor.putInt("x", 123)
+ val success = editor.commit()
+
+ assertThat(success).isTrue()
+ assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "123"))
+ verify(storagePersisterOps).put("x", "123")
+ verifyNoMoreInteractions(storagePersisterOps)
+ }
+
+ @Test
+ fun putLong() {
+ prepareStoragePersisterMock()
+
+ editor.putLong("x", 1234)
+ val success = editor.commit()
+
+ assertThat(success).isTrue()
+ assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "1234"))
+ verify(storagePersisterOps).put("x", "1234")
+ verifyNoMoreInteractions(storagePersisterOps)
+ }
+
+ @Test
+ fun putString() {
+ prepareStoragePersisterMock()
+
+ editor.putString("x", "y")
+ val success = editor.commit()
+
+ assertThat(success).isTrue()
+ assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "y"))
+ verify(storagePersisterOps).put("x", "y")
+ verifyNoMoreInteractions(storagePersisterOps)
+ }
+
+ @Test
+ fun putString_duplicateSame() {
+ prepareStoragePersisterMock()
+
+ editor.putString("storage-key", "storage-value")
+ val success = editor.commit()
+
+ assertThat(success).isTrue()
+ assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value"))
+ verifyNoMoreInteractions(storagePersisterOps)
+ }
+
+ @Test
+ fun putString_duplicateOther() {
+ prepareStoragePersisterMock()
+
+ editor.putString("storage-key", "other-value")
+ val success = editor.commit()
+
+ assertThat(success).isTrue()
+ assertThat(newValues).isEqualTo(mapOf("storage-key" to "other-value"))
+ verify(storagePersisterOps).put("storage-key", "other-value")
+ verifyNoMoreInteractions(storagePersisterOps)
+ }
+
+ @Test
+ fun putString_removedDuplicate() {
+ prepareStoragePersisterMock()
+
+ editor.remove("storage-key")
+ editor.putString("storage-key", "storage-value")
+ val success = editor.commit()
+
+ assertThat(success).isTrue()
+ assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value"))
+ verify(storagePersisterOps).remove("storage-key")
+ verify(storagePersisterOps).put("storage-key", "storage-value")
+ verifyNoMoreInteractions(storagePersisterOps)
+ }
+
+ @Test
+ fun `remove key that doesn't exist`() {
+ prepareStoragePersisterMock()
+
+ editor.remove("x")
+ val success = editor.commit()
+
+ assertThat(success).isTrue()
+ assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value"))
+ verify(storagePersisterOps).remove("x")
+ verifyNoMoreInteractions(storagePersisterOps)
+ }
+
+ @Test
+ fun remove() {
+ prepareStoragePersisterMock()
+
+ editor.remove("storage-key")
+ val success = editor.commit()
+
+ assertThat(success).isTrue()
+ assertThat(newValues).isEmpty()
+ verify(storagePersisterOps).remove("storage-key")
+ verifyNoMoreInteractions(storagePersisterOps)
+ }
+
+ private fun prepareStoragePersisterMock() {
+ stubbing(storagePersisterOps) {
+ on { put(any(), any()) } doAnswer {
+ val key = it.getArgument<String>(0)
+ val value = it.getArgument<String>(1)
+ workingMap[key] = value
+ }
+
+ on { remove(any()) } doAnswer {
+ val key = it.getArgument<String>(0)
+ workingMap.remove(key)
+
+ Unit
+ }
+ }
+
+ stubbing(storagePersister) {
+ on { doInTransaction(any()) } doAnswer {
+ val operationCallback = it.getArgument<StoragePersistOperationCallback>(0)
+
+ operationCallback.beforePersistTransaction(workingMap)
+ operationCallback.persist(storagePersisterOps)
+ operationCallback.onPersistTransactionSuccess(workingMap)
+ }
+ }
+ }
+}
+
+class TestStorageUpdater(private val currentStorage: Storage) : StorageUpdater {
+ var newStorage: Storage? = null
+
+ override fun updateStorage(updater: (currentStorage: Storage) -> Storage) {
+ newStorage = updater(currentStorage)
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt b/app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt
new file mode 100644
index 0000000..95898e1
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt
@@ -0,0 +1,120 @@
+package com.fsck.k9.preferences
+
+import android.content.Context
+import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperationCallback
+import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperations
+import com.fsck.k9.storage.K9RobolectricTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.inOrder
+import org.mockito.kotlin.never
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.verifyNoMoreInteractions
+import org.robolectric.RuntimeEnvironment
+
+class StoragePersisterTest : K9RobolectricTest() {
+ private var context: Context = RuntimeEnvironment.getApplication()
+ private var storagePersister = K9StoragePersister(context)
+
+ @Test
+ fun doInTransaction_order() {
+ val operationCallback = prepareCallback()
+ storagePersister.doInTransaction(operationCallback)
+
+ inOrder(operationCallback) {
+ verify(operationCallback).beforePersistTransaction(any())
+ verify(operationCallback).persist(any())
+ verify(operationCallback).onPersistTransactionSuccess(any())
+ }
+ verifyNoMoreInteractions(operationCallback)
+ }
+
+ @Test
+ fun doInTransaction_put() {
+ val operationCallback = prepareCallback(
+ persistOp = { ops -> ops.put("x", "y") },
+ onSuccess = { map ->
+ assertEquals(1, map.size)
+ assertEquals("y", map["x"])
+ }
+ )
+
+ storagePersister.doInTransaction(operationCallback)
+
+ val values = storagePersister.loadValues().all
+ assertEquals(1, values.size)
+ assertEquals("y", values["x"])
+ }
+
+ @Test
+ fun doInTransaction_putAndThrow() {
+ val exception = Exception("boom")
+ val operationCallback = prepareCallback(
+ persistOp = { ops ->
+ ops.put("x", "y")
+ throw exception
+ }
+ )
+
+ try {
+ storagePersister.doInTransaction(operationCallback)
+ fail("expected exception")
+ } catch (e: Exception) {
+ assertSame(exception, e)
+ }
+
+ val values = storagePersister.loadValues()
+ assertTrue(values.isEmpty())
+ verify(operationCallback, never()).onPersistTransactionSuccess(any())
+ }
+
+ @Test
+ fun doInTransaction_remove() {
+ val operationCallback = prepareCallback(
+ before = { map -> map["x"] = "y" },
+ persistOp = { ops -> ops.remove("x") },
+ onSuccess = { map -> assertTrue(map.isEmpty()) }
+ )
+
+ storagePersister.doInTransaction(operationCallback)
+
+ val values = storagePersister.loadValues()
+ assertTrue(values.isEmpty())
+ }
+
+ @Test
+ fun doInTransaction_before_preserveButNotPersist() {
+ val operationCallback = prepareCallback(
+ before = { map -> map["x"] = "y" },
+ onSuccess = { map -> assertEquals("y", map["x"]) }
+ )
+
+ storagePersister.doInTransaction(operationCallback)
+
+ val values = storagePersister.loadValues()
+ assertTrue(values.isEmpty())
+ }
+
+ private fun prepareCallback(
+ persistOp: ((StoragePersistOperations) -> Unit)? = null,
+ before: ((MutableMap<String, String>) -> Unit)? = null,
+ onSuccess: ((Map<String, String>) -> Unit)? = null
+ ): StoragePersistOperationCallback = spy(object : StoragePersistOperationCallback {
+ override fun beforePersistTransaction(workingStorage: MutableMap<String, String>) {
+ before?.invoke(workingStorage)
+ }
+
+ override fun persist(ops: StoragePersistOperations) {
+ persistOp?.invoke(ops)
+ }
+
+ override fun onPersistTransactionSuccess(workingStorage: Map<String, String>) {
+ onSuccess?.invoke(workingStorage)
+ }
+ })
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/K9RobolectricTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/K9RobolectricTest.kt
new file mode 100644
index 0000000..eeb24ec
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/K9RobolectricTest.kt
@@ -0,0 +1,16 @@
+package com.fsck.k9.storage
+
+import android.app.Application
+import org.junit.runner.RunWith
+import org.koin.test.AutoCloseKoinTest
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * A Robolectric test that creates an instance of our [Application] test class [TestApp].
+ *
+ * See also [RobolectricTest].
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = TestApp::class)
+abstract class K9RobolectricTest : AutoCloseKoinTest()
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/RobolectricTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/RobolectricTest.kt
new file mode 100644
index 0000000..0e40c60
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/RobolectricTest.kt
@@ -0,0 +1,15 @@
+package com.fsck.k9.storage
+
+import android.app.Application
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * A Robolectric test that does not create an instance of our [Application] class.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = EmptyApplication::class)
+abstract class RobolectricTest
+
+class EmptyApplication : Application()
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java b/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java
new file mode 100644
index 0000000..325eac1
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java
@@ -0,0 +1,424 @@
+package com.fsck.k9.storage;
+
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import android.app.Application;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.text.TextUtils;
+
+import com.fsck.k9.Account;
+import com.fsck.k9.core.BuildConfig;
+import com.fsck.k9.mail.AuthType;
+import com.fsck.k9.mail.ConnectionSecurity;
+import com.fsck.k9.mail.MessagingException;
+import com.fsck.k9.mail.ServerSettings;
+import com.fsck.k9.mailstore.LocalStore;
+import com.fsck.k9.mailstore.LockableDatabase;
+import com.fsck.k9.mailstore.MigrationsHelper;
+import com.fsck.k9.mailstore.StorageManager;
+import org.junit.Before;
+import org.junit.Test;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.shadows.ShadowLog;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+
+public class StoreSchemaDefinitionTest extends K9RobolectricTest {
+ private StoreSchemaDefinition storeSchemaDefinition;
+
+
+ @Before
+ public void setUp() throws MessagingException {
+ ShadowLog.stream = System.out;
+
+ Application application = RuntimeEnvironment.getApplication();
+ StorageManager.getInstance(application);
+
+ storeSchemaDefinition = createStoreSchemaDefinition();
+ }
+
+ @Test
+ public void getVersion_shouldReturnCurrentDatabaseVersion() {
+ int version = storeSchemaDefinition.getVersion();
+
+ assertEquals(StoreSchemaDefinition.DB_VERSION, version);
+ }
+
+ @Test
+ public void doDbUpgrade_withEmptyDatabase_shouldSetsDatabaseVersion() {
+ SQLiteDatabase database = SQLiteDatabase.create(null);
+
+ storeSchemaDefinition.doDbUpgrade(database);
+
+ assertEquals(StoreSchemaDefinition.DB_VERSION, database.getVersion());
+ }
+
+ @Test
+ public void doDbUpgrade_withBadDatabase_shouldThrowInDebugBuild() {
+ if (BuildConfig.DEBUG) {
+ SQLiteDatabase database = SQLiteDatabase.create(null);
+ database.setVersion(61);
+
+ try {
+ storeSchemaDefinition.doDbUpgrade(database);
+ fail("Expected Error");
+ } catch (Error e) {
+ assertEquals("Exception while upgrading database", e.getMessage());
+ }
+ }
+ }
+
+ @Test
+ public void doDbUpgrade_withV61_shouldUpgradeDatabaseToLatestVersion() {
+ SQLiteDatabase database = createV61Database();
+
+ storeSchemaDefinition.doDbUpgrade(database);
+
+ assertEquals(StoreSchemaDefinition.DB_VERSION, database.getVersion());
+ }
+
+ @Test
+ public void doDbUpgrade_withV61() {
+ SQLiteDatabase database = createV61Database();
+ insertMessageWithSubject(database, "Test Email");
+
+ storeSchemaDefinition.doDbUpgrade(database);
+
+ assertMessageWithSubjectExists(database, "Test Email");
+ }
+
+ @Test
+ public void doDbUpgrade_fromV61_shouldResultInSameTables() {
+ SQLiteDatabase newDatabase = createNewDatabase();
+ SQLiteDatabase upgradedDatabase = createV61Database();
+
+ storeSchemaDefinition.doDbUpgrade(upgradedDatabase);
+
+ assertDatabaseTablesEquals(newDatabase, upgradedDatabase);
+ }
+
+ @Test
+ public void doDbUpgrade_fromV61_shouldResultInSameTriggers() {
+ SQLiteDatabase newDatabase = createNewDatabase();
+ SQLiteDatabase upgradedDatabase = createV61Database();
+
+ storeSchemaDefinition.doDbUpgrade(upgradedDatabase);
+
+ assertDatabaseTriggersEquals(newDatabase, upgradedDatabase);
+ }
+
+ @Test
+ public void doDbUpgrade_fromV61_shouldResultInSameIndexes() {
+ SQLiteDatabase newDatabase = createNewDatabase();
+ SQLiteDatabase upgradedDatabase = createV61Database();
+
+ storeSchemaDefinition.doDbUpgrade(upgradedDatabase);
+
+ assertDatabaseIndexesEquals(newDatabase, upgradedDatabase);
+ }
+
+
+ private SQLiteDatabase createV61Database() {
+ SQLiteDatabase database = SQLiteDatabase.create(null);
+ initV61Database(database);
+ return database;
+ }
+
+ private void initV61Database(SQLiteDatabase db) {
+
+ db.beginTransaction();
+
+ db.execSQL("DROP TABLE IF EXISTS folders");
+ db.execSQL("CREATE TABLE folders (" +
+ "id INTEGER PRIMARY KEY," +
+ "name TEXT, " +
+ "last_updated INTEGER, " +
+ "unread_count INTEGER, " +
+ "visible_limit INTEGER, " +
+ "status TEXT, " +
+ "push_state TEXT, " +
+ "last_pushed INTEGER, " +
+ "flagged_count INTEGER default 0, " +
+ "integrate INTEGER, " +
+ "top_group INTEGER, " +
+ "poll_class TEXT, " +
+ "push_class TEXT, " +
+ "display_class TEXT, " +
+ "notify_class TEXT default 'INHERITED', " +
+ "more_messages TEXT default \"unknown\"" +
+ ")");
+
+ db.execSQL("CREATE INDEX IF NOT EXISTS folder_name ON folders (name)");
+ db.execSQL("DROP TABLE IF EXISTS messages");
+ db.execSQL("CREATE TABLE messages (" +
+ "id INTEGER PRIMARY KEY, " +
+ "deleted INTEGER default 0, " +
+ "folder_id INTEGER, " +
+ "uid TEXT, " +
+ "subject TEXT, " +
+ "date INTEGER, " +
+ "flags TEXT, " +
+ "sender_list TEXT, " +
+ "to_list TEXT, " +
+ "cc_list TEXT, " +
+ "bcc_list TEXT, " +
+ "reply_to_list TEXT, " +
+ "attachment_count INTEGER, " +
+ "internal_date INTEGER, " +
+ "message_id TEXT, " +
+ "preview_type TEXT default \"none\", " +
+ "preview TEXT, " +
+ "mime_type TEXT, "+
+ "normalized_subject_hash INTEGER, " +
+ "empty INTEGER default 0, " +
+ "read INTEGER default 0, " +
+ "flagged INTEGER default 0, " +
+ "answered INTEGER default 0, " +
+ "forwarded INTEGER default 0, " +
+ "message_part_id INTEGER" +
+ ")");
+
+ db.execSQL("DROP TABLE IF EXISTS message_parts");
+ db.execSQL("CREATE TABLE message_parts (" +
+ "id INTEGER PRIMARY KEY, " +
+ "type INTEGER NOT NULL, " +
+ "root INTEGER, " +
+ "parent INTEGER NOT NULL, " +
+ "seq INTEGER NOT NULL, " +
+ "mime_type TEXT, " +
+ "decoded_body_size INTEGER, " +
+ "display_name TEXT, " +
+ "header TEXT, " +
+ "encoding TEXT, " +
+ "charset TEXT, " +
+ "data_location INTEGER NOT NULL, " +
+ "data BLOB, " +
+ "preamble TEXT, " +
+ "epilogue TEXT, " +
+ "boundary TEXT, " +
+ "content_id TEXT, " +
+ "server_extra TEXT" +
+ ")");
+
+ db.execSQL("CREATE TRIGGER set_message_part_root " +
+ "AFTER INSERT ON message_parts " +
+ "BEGIN " +
+ "UPDATE message_parts SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " +
+ "END");
+
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_uid ON messages (uid, folder_id)");
+ db.execSQL("DROP INDEX IF EXISTS msg_folder_id");
+ db.execSQL("DROP INDEX IF EXISTS msg_folder_id_date");
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_folder_id_deleted_date ON messages (folder_id,deleted,internal_date)");
+
+ db.execSQL("DROP INDEX IF EXISTS msg_empty");
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_empty ON messages (empty)");
+
+ db.execSQL("DROP INDEX IF EXISTS msg_read");
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_read ON messages (read)");
+
+ db.execSQL("DROP INDEX IF EXISTS msg_flagged");
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_flagged ON messages (flagged)");
+
+ db.execSQL("DROP INDEX IF EXISTS msg_composite");
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_composite ON messages (deleted, empty,folder_id,flagged,read)");
+
+
+ db.execSQL("DROP TABLE IF EXISTS threads");
+ db.execSQL("CREATE TABLE threads (" +
+ "id INTEGER PRIMARY KEY, " +
+ "message_id INTEGER, " +
+ "root INTEGER, " +
+ "parent INTEGER" +
+ ")");
+
+ db.execSQL("DROP INDEX IF EXISTS threads_message_id");
+ db.execSQL("CREATE INDEX IF NOT EXISTS threads_message_id ON threads (message_id)");
+
+ db.execSQL("DROP INDEX IF EXISTS threads_root");
+ db.execSQL("CREATE INDEX IF NOT EXISTS threads_root ON threads (root)");
+
+ db.execSQL("DROP INDEX IF EXISTS threads_parent");
+ db.execSQL("CREATE INDEX IF NOT EXISTS threads_parent ON threads (parent)");
+
+ db.execSQL("DROP TRIGGER IF EXISTS set_thread_root");
+ db.execSQL("CREATE TRIGGER set_thread_root " +
+ "AFTER INSERT ON threads " +
+ "BEGIN " +
+ "UPDATE threads SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " +
+ "END");
+
+ db.execSQL("DROP TABLE IF EXISTS pending_commands");
+ db.execSQL("CREATE TABLE pending_commands " +
+ "(id INTEGER PRIMARY KEY, command TEXT, data TEXT)");
+
+ db.execSQL("DROP TRIGGER IF EXISTS delete_folder");
+ db.execSQL("CREATE TRIGGER delete_folder BEFORE DELETE ON folders BEGIN DELETE FROM messages WHERE old.id = folder_id; END;");
+
+ db.execSQL("DROP TRIGGER IF EXISTS delete_message");
+ db.execSQL("CREATE TRIGGER delete_message " +
+ "BEFORE DELETE ON messages " +
+ "BEGIN " +
+ "DELETE FROM message_parts WHERE root = OLD.message_part_id; " +
+ "DELETE FROM messages_fulltext WHERE docid = OLD.id; " +
+ "END");
+
+ db.execSQL("DROP TABLE IF EXISTS messages_fulltext");
+ db.execSQL("CREATE VIRTUAL TABLE messages_fulltext USING fts4 (fulltext)");
+
+ db.setVersion(61);
+
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ }
+
+ private void assertMessageWithSubjectExists(SQLiteDatabase database, String subject) {
+ Cursor cursor = database.query("messages", new String[] { "subject" }, null, null, null, null, null);
+ try {
+ assertTrue(cursor.moveToFirst());
+ assertEquals(subject, cursor.getString(0));
+ } finally {
+ cursor.close();
+ }
+ }
+
+ private void assertDatabaseTablesEquals(SQLiteDatabase expected, SQLiteDatabase actual) {
+ List<String> tablesInNewDatabase = tablesInDatabase(expected);
+ Collections.sort(tablesInNewDatabase);
+
+ List<String> tablesInUpgradedDatabase = tablesInDatabase(actual);
+ Collections.sort(tablesInUpgradedDatabase);
+
+ assertEquals(tablesInNewDatabase, tablesInUpgradedDatabase);
+ }
+
+ private void assertDatabaseTriggersEquals(SQLiteDatabase expected, SQLiteDatabase actual) {
+ List<String> triggersInNewDatabase = triggersInDatabase(expected);
+ Collections.sort(triggersInNewDatabase);
+
+ List<String> triggersInUpgradedDatabase = triggersInDatabase(actual);
+ Collections.sort(triggersInUpgradedDatabase);
+
+ assertEquals(triggersInNewDatabase, triggersInUpgradedDatabase);
+ }
+
+ private void assertDatabaseIndexesEquals(SQLiteDatabase expected, SQLiteDatabase actual) {
+ List<String> indexesInNewDatabase = indexesInDatabase(expected);
+ Collections.sort(indexesInNewDatabase);
+
+ List<String> indexesInUpgradedDatabase = indexesInDatabase(actual);
+ Collections.sort(indexesInUpgradedDatabase);
+
+ assertEquals(indexesInNewDatabase, indexesInUpgradedDatabase);
+ }
+
+ private List<String> tablesInDatabase(SQLiteDatabase db) {
+ return objectsInDatabase(db, "table");
+ }
+
+ private List<String> triggersInDatabase(SQLiteDatabase db) {
+ return objectsInDatabase(db, "trigger");
+ }
+
+ private List<String> indexesInDatabase(SQLiteDatabase db) {
+ return objectsInDatabase(db, "index");
+ }
+
+ private List<String> objectsInDatabase(SQLiteDatabase db, String type) {
+ List<String> databaseObjects = new ArrayList<>();
+ Cursor cursor = db.rawQuery("SELECT sql FROM sqlite_master WHERE type = ? AND sql IS NOT NULL",
+ new String[] { type });
+ try {
+ while (cursor.moveToNext()) {
+ String sql = cursor.getString(cursor.getColumnIndex("sql"));
+ String resortedSql = "table".equals(type) ? sortTableColumns(sql) : sql;
+ databaseObjects.add(resortedSql);
+ }
+ } finally {
+ cursor.close();
+ }
+
+ return databaseObjects;
+ }
+
+ private String sortTableColumns(String sql) {
+ int positionOfColumnDefinitions = sql.indexOf('(');
+ String columnDefinitionsSql = sql.substring(positionOfColumnDefinitions + 1, sql.length() - 1);
+ String[] columnDefinitions = columnDefinitionsSql.split(" *, *(?![^(]*\\))");
+ Arrays.sort(columnDefinitions);
+
+ String sqlPrefix = sql.substring(0, positionOfColumnDefinitions + 1);
+ String sortedColumnDefinitionsSql = TextUtils.join(", ", columnDefinitions);
+ return sqlPrefix + sortedColumnDefinitionsSql + ")";
+ }
+
+ private void insertMessageWithSubject(SQLiteDatabase database, String subject) {
+ ContentValues data = new ContentValues();
+ data.put("subject", subject);
+ long rowId = database.insert("messages", null, data);
+ assertNotEquals(-1, rowId);
+ }
+
+ private StoreSchemaDefinition createStoreSchemaDefinition() throws MessagingException {
+ final Account account = createAccount();
+ final LockableDatabase lockableDatabase = createLockableDatabase();
+ final LocalStore localStore = mock(LocalStore.class);
+ when(localStore.getDatabase()).thenReturn(lockableDatabase);
+
+ MigrationsHelper migrationsHelper = new MigrationsHelper() {
+ @Override
+ public Account getAccount() {
+ return account;
+ }
+
+ @Override
+ public void saveAccount() {
+ // Do nothing
+ }
+ };
+
+ return new StoreSchemaDefinition(migrationsHelper);
+ }
+
+ private LockableDatabase createLockableDatabase() throws MessagingException {
+ LockableDatabase lockableDatabase = mock(LockableDatabase.class);
+ when(lockableDatabase.execute(anyBoolean(), any(LockableDatabase.DbCallback.class))).thenReturn(false);
+ return lockableDatabase;
+ }
+
+ private Account createAccount() {
+ Account account = mock(Account.class);
+ when(account.getLegacyInboxFolder()).thenReturn("Inbox");
+ when(account.getImportedTrashFolder()).thenReturn("Trash");
+ when(account.getImportedDraftsFolder()).thenReturn("Drafts");
+ when(account.getImportedSpamFolder()).thenReturn("Spam");
+ when(account.getImportedSentFolder()).thenReturn("Sent");
+ when(account.getImportedArchiveFolder()).thenReturn(null);
+ when(account.getLocalStorageProviderId()).thenReturn(StorageManager.InternalStorageProvider.ID);
+
+ ServerSettings incomingServerSettings = new ServerSettings("dummy", "", -1, ConnectionSecurity.NONE,
+ AuthType.AUTOMATIC, "", "", null);
+ when(account.getIncomingServerSettings()).thenReturn(incomingServerSettings);
+ return account;
+ }
+
+ private SQLiteDatabase createNewDatabase() {
+ SQLiteDatabase database = SQLiteDatabase.create(null);
+ storeSchemaDefinition.doDbUpgrade(database);
+ return database;
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt b/app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt
new file mode 100644
index 0000000..97dbdb0
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt
@@ -0,0 +1,35 @@
+package com.fsck.k9.storage
+
+import android.app.Application
+import com.fsck.k9.AppConfig
+import com.fsck.k9.Core
+import com.fsck.k9.CoreResourceProvider
+import com.fsck.k9.DI
+import com.fsck.k9.K9
+import com.fsck.k9.backend.BackendManager
+import com.fsck.k9.coreModules
+import com.fsck.k9.crypto.EncryptionExtractor
+import com.fsck.k9.preferences.K9StoragePersister
+import com.fsck.k9.preferences.StoragePersister
+import org.koin.dsl.module
+import org.mockito.kotlin.mock
+
+class TestApp : Application() {
+ override fun onCreate() {
+ Core.earlyInit()
+
+ super.onCreate()
+ DI.start(this, coreModules + storageModule + testModule)
+
+ K9.init(this)
+ Core.init(this)
+ }
+}
+
+val testModule = module {
+ single { AppConfig(emptyList()) }
+ single { mock<CoreResourceProvider>() }
+ single { mock<EncryptionExtractor>() }
+ single<StoragePersister> { K9StoragePersister(get()) }
+ single { mock<BackendManager>() }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/CheckFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/CheckFolderOperationsTest.kt
new file mode 100644
index 0000000..1c25c54
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/CheckFolderOperationsTest.kt
@@ -0,0 +1,65 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.isFalse
+import assertk.assertions.isTrue
+import com.fsck.k9.storage.RobolectricTest
+import org.junit.Test
+
+class CheckFolderOperationsTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+ private val checkFolderOperations = CheckFolderOperations(lockableDatabase)
+
+ @Test
+ fun `single folder not included in Unified Inbox`() {
+ val folderIds = listOf(sqliteDatabase.createFolder(integrate = false))
+
+ val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
+
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `single folder included in Unified Inbox`() {
+ val folderIds = listOf(sqliteDatabase.createFolder(integrate = true))
+
+ val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
+
+ assertThat(result).isTrue()
+ }
+
+ @Test
+ fun `not all folders included in Unified Inbox`() {
+ val folderIds = listOf(
+ sqliteDatabase.createFolder(integrate = true),
+ sqliteDatabase.createFolder(integrate = false)
+ )
+
+ val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
+
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `1000 folders included in Unified Inbox`() {
+ val folderIds = List(1000) {
+ sqliteDatabase.createFolder(integrate = true)
+ }
+
+ val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
+
+ assertThat(result).isTrue()
+ }
+
+ @Test
+ fun `999 of 1000 folders included in Unified Inbox`() {
+ val folderIds = List(999) {
+ sqliteDatabase.createFolder(integrate = true)
+ } + sqliteDatabase.createFolder(integrate = false)
+
+ val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
+
+ assertThat(result).isFalse()
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperationsTest.kt
new file mode 100644
index 0000000..77dc7b0
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperationsTest.kt
@@ -0,0 +1,114 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.isEqualTo
+import org.junit.Assert.fail
+import org.junit.Test
+
+class ChunkedDatabaseOperationsTest {
+ @Test(expected = IllegalArgumentException::class)
+ fun `empty list`() {
+ performChunkedOperation(
+ arguments = emptyList(),
+ argumentTransformation = Int::toString,
+ operation = ::failCallback
+ )
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `chunkSize = 0`() {
+ performChunkedOperation(
+ arguments = listOf(1),
+ argumentTransformation = Int::toString,
+ chunkSize = 0,
+ operation = ::failCallback
+ )
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `chunkSize = 1001`() {
+ performChunkedOperation(
+ arguments = listOf(1),
+ argumentTransformation = Int::toString,
+ chunkSize = 1001,
+ operation = ::failCallback
+ )
+ }
+
+ @Test
+ fun `single item`() {
+ val chunks = mutableListOf<Pair<String, Array<String>>>()
+
+ performChunkedOperation(
+ arguments = listOf(1),
+ argumentTransformation = Int::toString
+ ) { selectionSet, selectionArguments ->
+ chunks.add(selectionSet to selectionArguments)
+ Unit
+ }
+
+ assertThat(chunks).hasSize(1)
+ with(chunks.first()) {
+ assertThat(first).isEqualTo("IN (?)")
+ assertThat(second).isEqualTo(arrayOf("1"))
+ }
+ }
+
+ @Test
+ fun `2 items with chunk size of 1`() {
+ val chunks = mutableListOf<Pair<String, Array<String>>>()
+
+ performChunkedOperation(
+ arguments = listOf(1, 2),
+ argumentTransformation = Int::toString,
+ chunkSize = 1
+ ) { selectionSet, selectionArguments ->
+ chunks.add(selectionSet to selectionArguments)
+ Unit
+ }
+
+ assertThat(chunks).hasSize(2)
+ with(chunks[0]) {
+ assertThat(first).isEqualTo("IN (?)")
+ assertThat(second).isEqualTo(arrayOf("1"))
+ }
+ with(chunks[1]) {
+ assertThat(first).isEqualTo("IN (?)")
+ assertThat(second).isEqualTo(arrayOf("2"))
+ }
+ }
+
+ @Test
+ fun `14 items with chunk size of 5`() {
+ val chunks = mutableListOf<Pair<String, Array<String>>>()
+
+ performChunkedOperation(
+ arguments = (1..14).toList(),
+ argumentTransformation = Int::toString,
+ chunkSize = 5
+ ) { selectionSet, selectionArguments ->
+ chunks.add(selectionSet to selectionArguments)
+ Unit
+ }
+
+ assertThat(chunks).hasSize(3)
+ with(chunks[0]) {
+ assertThat(first).isEqualTo("IN (?,?,?,?,?)")
+ assertThat(second).isEqualTo(arrayOf("1", "2", "3", "4", "5"))
+ }
+ with(chunks[1]) {
+ assertThat(first).isEqualTo("IN (?,?,?,?,?)")
+ assertThat(second).isEqualTo(arrayOf("6", "7", "8", "9", "10"))
+ }
+ with(chunks[2]) {
+ assertThat(first).isEqualTo("IN (?,?,?,?)")
+ assertThat(second).isEqualTo(arrayOf("11", "12", "13", "14"))
+ }
+ }
+
+ @Suppress("UNUSED_PARAMETER")
+ private fun failCallback(selectionSet: String, selectionArguments: Array<String>) {
+ fail("'operation' callback called when it shouldn't")
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/CopyMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/CopyMessageOperationsTest.kt
new file mode 100644
index 0000000..84ac34e
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/CopyMessageOperationsTest.kt
@@ -0,0 +1,252 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNotIn
+import assertk.assertions.isNull
+import com.fsck.k9.mail.crlf
+import com.fsck.k9.mailstore.StorageManager
+import com.fsck.k9.storage.RobolectricTest
+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
+ )
+ )
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/CreateFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/CreateFolderOperationsTest.kt
new file mode 100644
index 0000000..d3af642
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/CreateFolderOperationsTest.kt
@@ -0,0 +1,91 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.isEqualTo
+import com.fsck.k9.mail.FolderClass
+import com.fsck.k9.mail.FolderType
+import com.fsck.k9.mailstore.CreateFolderInfo
+import com.fsck.k9.mailstore.FolderSettings
+import com.fsck.k9.storage.RobolectricTest
+import org.junit.Test
+
+class CreateFolderOperationsTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+ private val createFolderOperations = CreateFolderOperations(lockableDatabase)
+
+ @Test
+ fun `create single folder`() {
+ createFolderOperations.createFolders(
+ listOf(
+ CreateFolderInfo(
+ serverId = "archived_messages",
+ name = "Archive",
+ type = FolderType.ARCHIVE,
+ settings = FolderSettings(
+ visibleLimit = 10,
+ displayClass = FolderClass.FIRST_CLASS,
+ syncClass = FolderClass.SECOND_CLASS,
+ notifyClass = FolderClass.NO_CLASS,
+ pushClass = FolderClass.NO_CLASS,
+ inTopGroup = true,
+ integrate = false
+ )
+ )
+ )
+ )
+
+ val folders = sqliteDatabase.readFolders()
+ assertThat(folders).hasSize(1)
+ val folder = folders.first()
+ assertThat(folder.serverId).isEqualTo("archived_messages")
+ assertThat(folder.name).isEqualTo("Archive")
+ assertThat(folder.type).isEqualTo("archive")
+ assertThat(folder.visibleLimit).isEqualTo(10)
+ assertThat(folder.displayClass).isEqualTo("FIRST_CLASS")
+ assertThat(folder.syncClass).isEqualTo("SECOND_CLASS")
+ assertThat(folder.notifyClass).isEqualTo("NO_CLASS")
+ assertThat(folder.pushClass).isEqualTo("NO_CLASS")
+ assertThat(folder.inTopGroup).isEqualTo(1)
+ assertThat(folder.integrate).isEqualTo(0)
+ }
+
+ @Test
+ fun `create multiple folders`() {
+ createFolderOperations.createFolders(
+ listOf(
+ createCreateFolderInfo(serverId = "folder1", name = "Inbox"),
+ createCreateFolderInfo(serverId = "folder2", name = "Sent"),
+ createCreateFolderInfo(serverId = "folder3", name = "Trash")
+ )
+ )
+
+ val folders = sqliteDatabase.readFolders()
+ assertThat(folders).hasSize(3)
+ assertThat(folders.map { it.serverId to it.name }.toSet()).isEqualTo(
+ setOf(
+ "folder1" to "Inbox",
+ "folder2" to "Sent",
+ "folder3" to "Trash"
+ )
+ )
+ }
+
+ fun createCreateFolderInfo(serverId: String, name: String): CreateFolderInfo {
+ return CreateFolderInfo(
+ serverId = serverId,
+ name = name,
+ type = FolderType.REGULAR,
+ settings = FolderSettings(
+ visibleLimit = 25,
+ displayClass = FolderClass.NO_CLASS,
+ syncClass = FolderClass.INHERITED,
+ notifyClass = FolderClass.INHERITED,
+ pushClass = FolderClass.NO_CLASS,
+ inTopGroup = false,
+ integrate = false
+ )
+ )
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteFolderOperationsTest.kt
new file mode 100644
index 0000000..9726ec4
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteFolderOperationsTest.kt
@@ -0,0 +1,62 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.containsExactly
+import assertk.assertions.extracting
+import assertk.assertions.hasSize
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNotNull
+import com.fsck.k9.mailstore.StorageManager
+import com.fsck.k9.storage.RobolectricTest
+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 DeleteFolderOperationsTest : 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 deleteFolderOperations = DeleteFolderOperations(lockableDatabase, attachmentFileManager)
+
+ @After
+ fun tearDown() {
+ messagePartDirectory.deleteRecursively()
+ }
+
+ @Test
+ fun `delete folder should remove message part files`() {
+ createFolderWithMessage("delete", "message1")
+ val messagePartId = createFolderWithMessage("retain", "message2")
+
+ deleteFolderOperations.deleteFolders(listOf("delete"))
+
+ val folders = sqliteDatabase.readFolders()
+ assertThat(folders).hasSize(1)
+ assertThat(folders.first().serverId).isEqualTo("retain")
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(1)
+ assertThat(messages.first().uid).isEqualTo("message2")
+
+ val messagePartFiles = messagePartDirectory.listFiles()
+ assertThat(messagePartFiles).isNotNull()
+ .extracting { it.name }.containsExactly(messagePartId.toString())
+ }
+
+ private fun createFolderWithMessage(folderServerId: String, messageServerId: String): Long {
+ val folderId = sqliteDatabase.createFolder(serverId = folderServerId)
+ val messagePartId = sqliteDatabase.createMessagePart(dataLocation = 2, directory = messagePartDirectory)
+ sqliteDatabase.createMessage(folderId = folderId, uid = messageServerId, messagePartId = messagePartId)
+
+ return messagePartId
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteMessageOperationsTest.kt
new file mode 100644
index 0000000..68425e7
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteMessageOperationsTest.kt
@@ -0,0 +1,196 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.containsExactlyInAnyOrder
+import assertk.assertions.extracting
+import assertk.assertions.hasSize
+import assertk.assertions.isEmpty
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNotNull
+import assertk.assertions.isNull
+import com.fsck.k9.mailstore.StorageManager
+import com.fsck.k9.storage.RobolectricTest
+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 DeleteMessageOperationsTest : 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 deleteMessageOperations = DeleteMessageOperations(lockableDatabase, attachmentFileManager)
+
+ @After
+ fun tearDown() {
+ messagePartDirectory.deleteRecursively()
+ }
+
+ @Test
+ fun `destroy message with empty parent messages`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(
+ folderId = folderId,
+ uid = "empty1",
+ empty = true,
+ messageIdHeader = "msg001@domain.example"
+ )
+ val messageId2 = sqliteDatabase.createMessage(
+ folderId = folderId,
+ uid = "empty2",
+ empty = true,
+ messageIdHeader = "msg002@domain.example"
+ )
+ val messageId3 = sqliteDatabase.createMessage(
+ folderId = folderId,
+ uid = "delete",
+ empty = false,
+ messageIdHeader = "msg003@domain.example"
+ )
+ val threadId1 = sqliteDatabase.createThread(messageId = messageId1)
+ val threadId2 = sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1)
+ sqliteDatabase.createThread(messageId = messageId3, root = threadId1, parent = threadId2)
+
+ deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete"))
+
+ assertThat(sqliteDatabase.readMessages()).isEmpty()
+ assertThat(sqliteDatabase.readThreads()).isEmpty()
+ }
+
+ @Test
+ fun `destroy message with empty parent message that has another child`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(
+ folderId = folderId,
+ uid = "empty",
+ empty = true,
+ messageIdHeader = "msg001@domain.example"
+ )
+ val messageId2 = sqliteDatabase.createMessage(
+ folderId = folderId,
+ uid = "child1",
+ empty = false,
+ messageIdHeader = "msg002@domain.example"
+ )
+ val messageId3 = sqliteDatabase.createMessage(
+ folderId = folderId,
+ uid = "delete",
+ empty = false,
+ messageIdHeader = "msg003@domain.example"
+ )
+ val threadId1 = sqliteDatabase.createThread(messageId = messageId1)
+ val threadId2 = sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1)
+ sqliteDatabase.createThread(messageId = messageId3, root = threadId1, parent = threadId1)
+
+ deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete"))
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(2)
+ assertThat(messages.map { it.id }.toSet()).isEqualTo(setOf(messageId1, messageId2))
+
+ val threads = sqliteDatabase.readThreads()
+ assertThat(threads).hasSize(2)
+ assertThat(threads.map { it.id }.toSet()).isEqualTo(setOf(threadId1, threadId2))
+ }
+
+ @Test
+ fun `destroy message with non-empty parent message`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(
+ folderId = folderId,
+ uid = "parent",
+ empty = false,
+ messageIdHeader = "msg001@domain.example"
+ )
+ val messageId2 = sqliteDatabase.createMessage(
+ folderId = folderId,
+ uid = "delete",
+ empty = false,
+ messageIdHeader = "msg002@domain.example"
+ )
+ val threadId1 = sqliteDatabase.createThread(messageId = messageId1)
+ sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1)
+
+ deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete"))
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(1)
+ assertThat(messages.first().id).isEqualTo(messageId1)
+
+ val threads = sqliteDatabase.readThreads()
+ assertThat(threads).hasSize(1)
+ assertThat(threads.first().id).isEqualTo(threadId1)
+ }
+
+ @Test
+ fun `destroy message without parent message`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(
+ folderId = folderId,
+ uid = "delete",
+ empty = false,
+ messageIdHeader = "msg001@domain.example"
+ )
+ sqliteDatabase.createThread(messageId = messageId1)
+
+ deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete"))
+
+ assertThat(sqliteDatabase.readMessages()).isEmpty()
+ assertThat(sqliteDatabase.readThreads()).isEmpty()
+ }
+
+ @Test
+ fun `destroy message with child message should convert to empty message`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(
+ folderId = folderId,
+ uid = "delete",
+ empty = false,
+ messageIdHeader = "msg001@domain.example"
+ )
+ val messageId2 = sqliteDatabase.createMessage(
+ folderId = folderId,
+ uid = "child",
+ empty = false,
+ messageIdHeader = "msg002@domain.example"
+ )
+ val threadId1 = sqliteDatabase.createThread(messageId = messageId1)
+ val threadId2 = sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1)
+
+ deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete"))
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(2)
+ val message1 = messages.first { it.id == messageId1 }
+ assertThat(message1.empty).isEqualTo(1)
+ assertThat(message1.subject).isNull()
+ assertThat(message1.date).isNull()
+ assertThat(message1.flags).isNull()
+ assertThat(message1.senderList).isNull()
+ assertThat(message1.toList).isNull()
+ assertThat(message1.ccList).isNull()
+ assertThat(message1.bccList).isNull()
+ assertThat(message1.replyToList).isNull()
+ assertThat(message1.attachmentCount).isNull()
+ assertThat(message1.internalDate).isNull()
+ assertThat(message1.previewType).isEqualTo("none")
+ assertThat(message1.preview).isNull()
+ assertThat(message1.mimeType).isNull()
+ assertThat(message1.normalizedSubjectHash).isNull()
+ assertThat(message1.messagePartId).isNull()
+ assertThat(message1.encryptionType).isNull()
+ assertThat(messages.firstOrNull { it.id == messageId2 }).isNotNull()
+
+ val threads = sqliteDatabase.readThreads()
+ assertThat(threads).hasSize(2)
+ assertThat(threads).extracting { it.id }.containsExactlyInAnyOrder(threadId1, threadId2)
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/FileHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/FileHelpers.kt
new file mode 100644
index 0000000..b78ff32
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/FileHelpers.kt
@@ -0,0 +1,10 @@
+package com.fsck.k9.storage.messages
+
+import java.io.File
+import java.util.UUID
+
+@Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
+fun createRandomTempDirectory(): File {
+ val tempDirectory = File(System.getProperty("java.io.tmpdir", "."))
+ return File(tempDirectory, UUID.randomUUID().toString()).also { it.mkdir() }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/FlagMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/FlagMessageOperationsTest.kt
new file mode 100644
index 0000000..8438694
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/FlagMessageOperationsTest.kt
@@ -0,0 +1,100 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.isEqualTo
+import assertk.assertions.isTrue
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.storage.RobolectricTest
+import org.junit.Test
+
+class FlagMessageOperationsTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+ private val flagMessageOperations = FlagMessageOperations(lockableDatabase)
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `empty messageIds list`() {
+ flagMessageOperations.setFlag(emptyList(), Flag.SEEN, true)
+ }
+
+ @Test
+ fun `mark one message as answered`() {
+ val messageId = sqliteDatabase.createMessage(folderId = 1, uid = "uid1", answered = false)
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid2", answered = false)
+ val messageIds = listOf(messageId)
+
+ flagMessageOperations.setFlag(messageIds, Flag.ANSWERED, true)
+
+ val messages = sqliteDatabase.readMessages()
+
+ val message = messages.find { it.id == messageId } ?: error("Original message not found")
+ assertThat(message.answered).isEqualTo(1)
+
+ val otherMessages = messages.filterNot { it.id == messageId }
+ assertThat(otherMessages).hasSize(1)
+ assertThat(otherMessages.all { it.answered == 0 }).isTrue()
+ }
+
+ @Test
+ fun `mark multiple messages as read`() {
+ val messageId1 = sqliteDatabase.createMessage(folderId = 1, uid = "uid1", read = false)
+ val messageId2 = sqliteDatabase.createMessage(folderId = 1, uid = "uid2", read = false)
+ val messageId3 = sqliteDatabase.createMessage(folderId = 1, uid = "uid3", read = false)
+ sqliteDatabase.createMessage(folderId = 1, uid = "uidx", read = false)
+ val messageIds = listOf(messageId1, messageId2, messageId3)
+
+ flagMessageOperations.setFlag(messageIds, Flag.SEEN, true)
+
+ val messages = sqliteDatabase.readMessages()
+
+ val affectedMessages = messages.filter { it.id in messageIds }
+ assertThat(affectedMessages).hasSize(3)
+ assertThat(affectedMessages.all { it.read == 1 }).isTrue()
+
+ val otherMessages = messages.filterNot { it.id in messageIds }
+ assertThat(otherMessages).hasSize(1)
+ assertThat(otherMessages.all { it.read == 0 }).isTrue()
+ }
+
+ @Test
+ fun `mark message as read`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", read = false)
+
+ flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.SEEN, true)
+
+ val message = sqliteDatabase.readMessages().first()
+ assertThat(message.read).isEqualTo(1)
+ }
+
+ @Test
+ fun `mark message as unread`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", read = true)
+
+ flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.SEEN, false)
+
+ val message = sqliteDatabase.readMessages().first()
+ assertThat(message.read).isEqualTo(0)
+ }
+
+ @Test
+ fun `mark message as X_DOWNLOADED_FULL`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", flags = "X_SUBJECT_DECRYPTED")
+
+ flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.X_DOWNLOADED_FULL, true)
+
+ val message = sqliteDatabase.readMessages().first()
+ val readFlags = message.flags!!.split(',').toSet()
+ assertThat(readFlags).isEqualTo(setOf("X_SUBJECT_DECRYPTED", "X_DOWNLOADED_FULL"))
+ }
+
+ @Test
+ fun `remove X_DOWNLOADED_FULL flag`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", flags = "X_DOWNLOADED_FULL")
+
+ flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.X_DOWNLOADED_FULL, false)
+
+ val message = sqliteDatabase.readMessages().first()
+ assertThat(message.flags).isEqualTo("")
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/FolderHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/FolderHelpers.kt
new file mode 100644
index 0000000..64fd21e
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/FolderHelpers.kt
@@ -0,0 +1,95 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import app.k9mail.core.android.common.database.getIntOrNull
+import app.k9mail.core.android.common.database.getLongOrNull
+import app.k9mail.core.android.common.database.getStringOrNull
+import app.k9mail.core.android.common.database.map
+
+fun SQLiteDatabase.createFolder(
+ name: String = "irrelevant",
+ type: String = "regular",
+ serverId: String? = null,
+ isLocalOnly: Boolean = true,
+ integrate: Boolean = false,
+ inTopGroup: Boolean = false,
+ displayClass: String = "NO_CLASS",
+ syncClass: String? = "INHERITED",
+ notifyClass: String? = "INHERITED",
+ pushClass: String? = "SECOND_CLASS",
+ lastUpdated: Long = 0L,
+ unreadCount: Int = 0,
+ visibleLimit: Int = 25,
+ status: String? = null,
+ flaggedCount: Int = 0,
+ moreMessages: String = "unknown"
+): Long {
+ val values = ContentValues().apply {
+ put("name", name)
+ put("type", type)
+ put("server_id", serverId)
+ put("local_only", isLocalOnly)
+ put("integrate", integrate)
+ put("top_group", inTopGroup)
+ put("display_class", displayClass)
+ put("poll_class", syncClass)
+ put("notify_class", notifyClass)
+ put("push_class", pushClass)
+ put("last_updated", lastUpdated)
+ put("unread_count", unreadCount)
+ put("visible_limit", visibleLimit)
+ put("status", status)
+ put("flagged_count", flaggedCount)
+ put("more_messages", moreMessages)
+ }
+
+ return insert("folders", null, values)
+}
+
+fun SQLiteDatabase.readFolders(): List<FolderEntry> {
+ val cursor = rawQuery("SELECT * FROM folders", null)
+ return cursor.use {
+ cursor.map {
+ FolderEntry(
+ id = cursor.getLongOrNull("id"),
+ name = cursor.getStringOrNull("name"),
+ type = cursor.getStringOrNull("type"),
+ serverId = cursor.getStringOrNull("server_id"),
+ isLocalOnly = cursor.getIntOrNull("local_only"),
+ integrate = cursor.getIntOrNull("integrate"),
+ inTopGroup = cursor.getIntOrNull("top_group"),
+ displayClass = cursor.getStringOrNull("display_class"),
+ syncClass = cursor.getStringOrNull("poll_class"),
+ notifyClass = cursor.getStringOrNull("notify_class"),
+ pushClass = cursor.getStringOrNull("push_class"),
+ lastUpdated = cursor.getLongOrNull("last_updated"),
+ unreadCount = cursor.getIntOrNull("unread_count"),
+ visibleLimit = cursor.getIntOrNull("visible_limit"),
+ status = cursor.getStringOrNull("status"),
+ flaggedCount = cursor.getIntOrNull("flagged_count"),
+ moreMessages = cursor.getStringOrNull("more_messages")
+ )
+ }
+ }
+}
+
+data class FolderEntry(
+ val id: Long?,
+ val name: String?,
+ val type: String?,
+ val serverId: String?,
+ val isLocalOnly: Int?,
+ val integrate: Int?,
+ val inTopGroup: Int?,
+ val displayClass: String?,
+ val syncClass: String?,
+ val notifyClass: String?,
+ val pushClass: String?,
+ val lastUpdated: Long?,
+ val unreadCount: Int?,
+ val visibleLimit: Int?,
+ val status: String?,
+ val flaggedCount: Int?,
+ val moreMessages: String?
+)
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueHelpers.kt
new file mode 100644
index 0000000..5af9132
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueHelpers.kt
@@ -0,0 +1,77 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import app.k9mail.core.android.common.database.getLongOrNull
+import app.k9mail.core.android.common.database.getStringOrNull
+import app.k9mail.core.android.common.database.map
+
+fun SQLiteDatabase.createExtraValue(
+ name: String = "irrelevant",
+ text: String? = null,
+ number: Long? = null
+): Long {
+ val values = ContentValues().apply {
+ put("name", name)
+ put("value_text", text)
+ put("value_integer", number)
+ }
+
+ return insert("account_extra_values", null, values)
+}
+
+fun SQLiteDatabase.readExtraValues(): List<ExtraValueEntry> {
+ val cursor = rawQuery("SELECT * FROM account_extra_values", null)
+ return cursor.use {
+ cursor.map {
+ ExtraValueEntry(
+ name = cursor.getStringOrNull("name"),
+ text = cursor.getStringOrNull("value_text"),
+ number = cursor.getLongOrNull("value_integer")
+ )
+ }
+ }
+}
+
+data class ExtraValueEntry(
+ val name: String?,
+ val text: String?,
+ val number: Long?
+)
+
+fun SQLiteDatabase.createFolderExtraValue(
+ folderId: Long,
+ name: String = "irrelevant",
+ text: String? = null,
+ number: Long? = null
+): Long {
+ val values = ContentValues().apply {
+ put("folder_id", folderId)
+ put("name", name)
+ put("value_text", text)
+ put("value_integer", number)
+ }
+
+ return insert("folder_extra_values", null, values)
+}
+
+fun SQLiteDatabase.readFolderExtraValues(): List<FolderExtraValueEntry> {
+ val cursor = rawQuery("SELECT * FROM folder_extra_values", null)
+ return cursor.use {
+ cursor.map {
+ FolderExtraValueEntry(
+ folderId = cursor.getLongOrNull("folder_id"),
+ name = cursor.getStringOrNull("name"),
+ text = cursor.getStringOrNull("value_text"),
+ number = cursor.getLongOrNull("value_integer")
+ )
+ }
+ }
+}
+
+data class FolderExtraValueEntry(
+ val folderId: Long?,
+ val name: String?,
+ val text: String?,
+ val number: Long?
+)
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueStoreOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueStoreOperationsTest.kt
new file mode 100644
index 0000000..f31c591
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueStoreOperationsTest.kt
@@ -0,0 +1,210 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNull
+import com.fsck.k9.storage.RobolectricTest
+import org.junit.Test
+
+class KeyValueStoreOperationsTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+ private val keyValueStoreOperations = KeyValueStoreOperations(lockableDatabase)
+
+ @Test
+ fun `get extra string`() {
+ sqliteDatabase.createExtraValue(name = "test", text = "Wurstsalat")
+
+ val result = keyValueStoreOperations.getExtraString("test")
+
+ assertThat(result).isEqualTo("Wurstsalat")
+ }
+
+ @Test
+ fun `get non-existent extra string`() {
+ val result = keyValueStoreOperations.getExtraString("test")
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `create extra string`() {
+ keyValueStoreOperations.setExtraString("jmapState", "ABC42")
+
+ val extraValues = sqliteDatabase.readExtraValues()
+ assertThat(extraValues).hasSize(1)
+ assertThat(extraValues.first()).isEqualTo(
+ ExtraValueEntry(
+ name = "jmapState",
+ text = "ABC42",
+ number = null
+ )
+ )
+ }
+
+ @Test
+ fun `update extra string`() {
+ sqliteDatabase.createExtraValue(name = "jmapState", text = "XYZ23")
+
+ keyValueStoreOperations.setExtraString("jmapState", "ABC42")
+
+ val extraValues = sqliteDatabase.readExtraValues()
+ assertThat(extraValues).hasSize(1)
+ assertThat(extraValues.first()).isEqualTo(
+ ExtraValueEntry(
+ name = "jmapState",
+ text = "ABC42",
+ number = null
+ )
+ )
+ }
+
+ @Test
+ fun `get extra number`() {
+ sqliteDatabase.createExtraValue(name = "test", number = 23)
+
+ val result = keyValueStoreOperations.getExtraNumber("test")
+
+ assertThat(result).isEqualTo(23)
+ }
+
+ @Test
+ fun `get non-existent extra number`() {
+ val result = keyValueStoreOperations.getExtraNumber("test")
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `create extra number`() {
+ keyValueStoreOperations.setExtraNumber("lastChanged", 123L)
+
+ val extraValues = sqliteDatabase.readExtraValues()
+ assertThat(extraValues).hasSize(1)
+ assertThat(extraValues.first()).isEqualTo(
+ ExtraValueEntry(
+ name = "lastChanged",
+ text = null,
+ number = 123L
+ )
+ )
+ }
+
+ @Test
+ fun `update extra number`() {
+ sqliteDatabase.createExtraValue(name = "lastChanged", number = 0L)
+
+ keyValueStoreOperations.setExtraNumber("lastChanged", 42L)
+
+ val extraValues = sqliteDatabase.readExtraValues()
+ assertThat(extraValues).hasSize(1)
+ assertThat(extraValues.first()).isEqualTo(
+ ExtraValueEntry(
+ name = "lastChanged",
+ text = null,
+ number = 42
+ )
+ )
+ }
+
+ @Test
+ fun `get extra folder string`() {
+ sqliteDatabase.createFolderExtraValue(folderId = 1, name = "test", text = "Wurstsalat")
+
+ val result = keyValueStoreOperations.getFolderExtraString(folderId = 1, name = "test")
+
+ assertThat(result).isEqualTo("Wurstsalat")
+ }
+
+ @Test
+ fun `get non-existent extra folder string`() {
+ val result = keyValueStoreOperations.getFolderExtraString(folderId = 1, name = "test")
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `create extra folder string`() {
+ keyValueStoreOperations.setFolderExtraString(folderId = 1, name = "imapUidValidity", value = "1")
+
+ val folderExtraValues = sqliteDatabase.readFolderExtraValues()
+ assertThat(folderExtraValues).hasSize(1)
+ assertThat(folderExtraValues.first()).isEqualTo(
+ FolderExtraValueEntry(
+ folderId = 1,
+ name = "imapUidValidity",
+ text = "1",
+ number = null
+ )
+ )
+ }
+
+ @Test
+ fun `update extra folder string`() {
+ sqliteDatabase.createFolderExtraValue(folderId = 1, name = "imapUidValidity", text = "23")
+
+ keyValueStoreOperations.setFolderExtraString(folderId = 1, name = "imapUidValidity", value = "42")
+
+ val folderExtraValues = sqliteDatabase.readFolderExtraValues()
+ assertThat(folderExtraValues).hasSize(1)
+ assertThat(folderExtraValues.first()).isEqualTo(
+ FolderExtraValueEntry(
+ folderId = 1,
+ name = "imapUidValidity",
+ text = "42",
+ number = null
+ )
+ )
+ }
+
+ @Test
+ fun `get extra folder number`() {
+ sqliteDatabase.createFolderExtraValue(folderId = 1, name = "test", number = 23)
+
+ val result = keyValueStoreOperations.getFolderExtraNumber(folderId = 1, name = "test")
+
+ assertThat(result).isEqualTo(23)
+ }
+
+ @Test
+ fun `get non-existent extra folder number`() {
+ val result = keyValueStoreOperations.getFolderExtraNumber(folderId = 1, name = "test")
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `create extra folder number`() {
+ keyValueStoreOperations.setFolderExtraNumber(folderId = 1, name = "lastChanged", value = 123L)
+
+ val folderExtraValues = sqliteDatabase.readFolderExtraValues()
+ assertThat(folderExtraValues).hasSize(1)
+ assertThat(folderExtraValues.first()).isEqualTo(
+ FolderExtraValueEntry(
+ folderId = 1,
+ name = "lastChanged",
+ text = null,
+ number = 123L
+ )
+ )
+ }
+
+ @Test
+ fun `update extra folder number`() {
+ sqliteDatabase.createFolderExtraValue(folderId = 1, name = "lastChanged", number = 0L)
+
+ keyValueStoreOperations.setFolderExtraNumber(folderId = 1, name = "lastChanged", value = 42L)
+
+ val folderExtraValues = sqliteDatabase.readFolderExtraValues()
+ assertThat(folderExtraValues).hasSize(1)
+ assertThat(folderExtraValues.first()).isEqualTo(
+ FolderExtraValueEntry(
+ folderId = 1,
+ name = "lastChanged",
+ text = null,
+ number = 42
+ )
+ )
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt
new file mode 100644
index 0000000..6ccbdbf
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt
@@ -0,0 +1,213 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import app.k9mail.core.android.common.database.getIntOrNull
+import app.k9mail.core.android.common.database.getLongOrNull
+import app.k9mail.core.android.common.database.getStringOrNull
+import app.k9mail.core.android.common.database.map
+import com.fsck.k9.mailstore.DatabasePreviewType
+import com.fsck.k9.mailstore.LockableDatabase
+import com.fsck.k9.mailstore.MigrationsHelper
+import com.fsck.k9.storage.K9SchemaDefinitionFactory
+import java.io.File
+import org.mockito.ArgumentMatchers
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.mock
+
+fun createLockableDatabaseMock(sqliteDatabase: SQLiteDatabase): LockableDatabase {
+ return mock {
+ on { execute(ArgumentMatchers.anyBoolean(), any<LockableDatabase.DbCallback<Any>>()) } doAnswer { stubbing ->
+ val callback: LockableDatabase.DbCallback<Any> = stubbing.getArgument(1)
+ callback.doDbWork(sqliteDatabase)
+ }
+ }
+}
+
+fun createDatabase(): SQLiteDatabase {
+ val migrationsHelper = mock<MigrationsHelper>()
+
+ val sqliteDatabase = SQLiteDatabase.create(null)
+ val schemaDefinitionFactory = K9SchemaDefinitionFactory()
+ val schemaDefinition = schemaDefinitionFactory.createSchemaDefinition(migrationsHelper)
+
+ schemaDefinition.doDbUpgrade(sqliteDatabase)
+
+ return sqliteDatabase
+}
+
+fun SQLiteDatabase.createMessage(
+ folderId: Long,
+ deleted: Boolean = false,
+ uid: String? = null,
+ subject: String = "",
+ date: Long = 0L,
+ flags: String = "",
+ senderList: String = "",
+ toList: String = "",
+ ccList: String = "",
+ bccList: String = "",
+ replyToList: String = "",
+ attachmentCount: Int = 0,
+ internalDate: Long = 0L,
+ messageIdHeader: String? = null,
+ previewType: DatabasePreviewType = DatabasePreviewType.NONE,
+ preview: String = "",
+ mimeType: String = "text/plain",
+ normalizedSubjectHash: Long = 0L,
+ empty: Boolean = false,
+ read: Boolean = false,
+ flagged: Boolean = false,
+ answered: Boolean = false,
+ forwarded: Boolean = false,
+ messagePartId: Long = 0L,
+ encryptionType: String? = null,
+ newMessage: Boolean = false
+): Long {
+ val values = ContentValues().apply {
+ put("deleted", if (deleted) 1 else 0)
+ put("folder_id", folderId)
+ put("uid", uid)
+ put("subject", subject)
+ put("date", date)
+ put("flags", flags)
+ put("sender_list", senderList)
+ put("to_list", toList)
+ put("cc_list", ccList)
+ put("bcc_list", bccList)
+ put("reply_to_list", replyToList)
+ put("attachment_count", attachmentCount)
+ put("internal_date", internalDate)
+ put("message_id", messageIdHeader)
+ put("preview_type", previewType.databaseValue)
+ put("preview", preview)
+ put("mime_type", mimeType)
+ put("normalized_subject_hash", normalizedSubjectHash)
+ put("empty", if (empty) 1 else 0)
+ put("read", if (read) 1 else 0)
+ put("flagged", if (flagged) 1 else 0)
+ put("answered", if (answered) 1 else 0)
+ put("forwarded", if (forwarded) 1 else 0)
+ put("message_part_id", messagePartId)
+ put("encryption_type", encryptionType)
+ put("new_message", if (newMessage) 1 else 0)
+ }
+
+ return insert("messages", null, values)
+}
+
+fun SQLiteDatabase.readMessages(): List<MessageEntry> {
+ val cursor = rawQuery("SELECT * FROM messages", null)
+ return cursor.use {
+ cursor.map {
+ MessageEntry(
+ id = cursor.getLongOrNull("id"),
+ deleted = cursor.getIntOrNull("deleted"),
+ folderId = cursor.getLongOrNull("folder_id"),
+ uid = cursor.getStringOrNull("uid"),
+ subject = cursor.getStringOrNull("subject"),
+ date = cursor.getLongOrNull("date"),
+ flags = cursor.getStringOrNull("flags"),
+ senderList = cursor.getStringOrNull("sender_list"),
+ toList = cursor.getStringOrNull("to_list"),
+ ccList = cursor.getStringOrNull("cc_list"),
+ bccList = cursor.getStringOrNull("bcc_list"),
+ replyToList = cursor.getStringOrNull("reply_to_list"),
+ attachmentCount = cursor.getIntOrNull("attachment_count"),
+ internalDate = cursor.getLongOrNull("internal_date"),
+ messageId = cursor.getStringOrNull("message_id"),
+ previewType = cursor.getStringOrNull("preview_type"),
+ preview = cursor.getStringOrNull("preview"),
+ mimeType = cursor.getStringOrNull("mime_type"),
+ normalizedSubjectHash = cursor.getLongOrNull("normalized_subject_hash"),
+ empty = cursor.getIntOrNull("empty"),
+ read = cursor.getIntOrNull("read"),
+ flagged = cursor.getIntOrNull("flagged"),
+ answered = cursor.getIntOrNull("answered"),
+ forwarded = cursor.getIntOrNull("forwarded"),
+ messagePartId = cursor.getLongOrNull("message_part_id"),
+ encryptionType = cursor.getStringOrNull("encryption_type"),
+ newMessage = cursor.getIntOrNull("new_message")
+ )
+ }
+ }
+}
+
+data class MessageEntry(
+ val id: Long?,
+ val deleted: Int?,
+ val folderId: Long?,
+ val uid: String?,
+ val subject: String?,
+ val date: Long?,
+ val flags: String?,
+ val senderList: String?,
+ val toList: String?,
+ val ccList: String?,
+ val bccList: String?,
+ val replyToList: String?,
+ val attachmentCount: Int?,
+ val internalDate: Long?,
+ val messageId: String?,
+ val previewType: String?,
+ val preview: String?,
+ val mimeType: String?,
+ val normalizedSubjectHash: Long?,
+ val empty: Int?,
+ val read: Int?,
+ val flagged: Int?,
+ val answered: Int?,
+ val forwarded: Int?,
+ val messagePartId: Long?,
+ val encryptionType: String?,
+ val newMessage: Int?
+)
+
+fun SQLiteDatabase.createMessagePart(
+ type: Int = 0,
+ root: Int? = null,
+ parent: Int = -1,
+ seq: Int = 0,
+ mimeType: String = "text/plain",
+ decodedBodySize: Int = 0,
+ displayName: String? = null,
+ header: String? = null,
+ encoding: String = "7bit",
+ charset: String? = null,
+ dataLocation: Int = 0,
+ data: ByteArray? = null,
+ preamble: String? = null,
+ epilogue: String? = null,
+ boundary: String? = null,
+ contentId: String? = null,
+ serverExtra: String? = null,
+ directory: File? = 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?.toByteArray())
+ 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).also { messagePartId ->
+ if (dataLocation == DATA_LOCATION_ON_DISK) {
+ requireNotNull(directory) { "Argument 'directory' can't be null when 'dataLocation = 2'" }
+ File(directory, messagePartId.toString()).createNewFile()
+ }
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/MessagePartDatabaseHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessagePartDatabaseHelpers.kt
new file mode 100644
index 0000000..19e915e
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessagePartDatabaseHelpers.kt
@@ -0,0 +1,182 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import app.k9mail.core.android.common.database.getIntOrNull
+import app.k9mail.core.android.common.database.getLongOrNull
+import app.k9mail.core.android.common.database.getStringOrNull
+import app.k9mail.core.android.common.database.map
+
+fun SQLiteDatabase.createMessagePart(
+ type: Int = MessagePartType.UNKNOWN,
+ root: Long? = null,
+ parent: Long = -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?.toByteArray())
+ 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")
+ )
+ }
+ }
+}
+
+data 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?
+) {
+ 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)
+ return if (isNull(columnIndex)) null else getBlob(columnIndex)
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/MoveMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/MoveMessageOperationsTest.kt
new file mode 100644
index 0000000..00dc9e4
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/MoveMessageOperationsTest.kt
@@ -0,0 +1,216 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNotNull
+import assertk.assertions.isNull
+import assertk.assertions.startsWith
+import com.fsck.k9.K9
+import com.fsck.k9.storage.RobolectricTest
+import org.junit.Test
+import org.junit.Assert.fail as junitFail
+
+private const val SOURCE_FOLDER_ID = 3L
+private const val DESTINATION_FOLDER_ID = 23L
+private const val MESSAGE_ID_HEADER = "<00000000-0000-4000-0000-000000000000@domain.example>"
+
+class MoveMessageOperationsTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+ private val moveMessageOperations = MoveMessageOperations(lockableDatabase, ThreadMessageOperations())
+
+ @Test
+ fun `move message not part of a thread`() {
+ val originalMessageId = sqliteDatabase.createMessage(
+ folderId = SOURCE_FOLDER_ID,
+ uid = "uid1",
+ subject = "Move me",
+ messageIdHeader = MESSAGE_ID_HEADER
+ )
+ sqliteDatabase.createThread(messageId = originalMessageId)
+ val originalMessage = sqliteDatabase.readMessages().first()
+
+ val destinationMessageId = moveMessageOperations.moveMessage(
+ messageId = originalMessageId,
+ destinationFolderId = DESTINATION_FOLDER_ID
+ )
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(2)
+
+ 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.first { it.id == destinationMessageId }
+ assertThat(destinationMessage.uid).isNotNull().startsWith(K9.LOCAL_UID_PREFIX)
+ assertThat(destinationMessage).isEqualTo(
+ originalMessage.copy(
+ id = destinationMessageId,
+ folderId = DESTINATION_FOLDER_ID,
+ uid = destinationMessage.uid,
+ deleted = 0,
+ 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
+ fun `move message when destination has empty message entry`() {
+ val originalMessageId = sqliteDatabase.createMessage(
+ folderId = SOURCE_FOLDER_ID,
+ uid = "uid1",
+ subject = "Move me",
+ messageIdHeader = MESSAGE_ID_HEADER,
+ read = false
+ )
+ sqliteDatabase.createThread(messageId = originalMessageId)
+ val originalMessage = sqliteDatabase.readMessages().first()
+ val placeholderMessageId = sqliteDatabase.createMessage(
+ empty = true,
+ folderId = DESTINATION_FOLDER_ID,
+ messageIdHeader = MESSAGE_ID_HEADER,
+ uid = ""
+ )
+ 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 destinationMessageId = moveMessageOperations.moveMessage(
+ messageId = originalMessageId,
+ destinationFolderId = DESTINATION_FOLDER_ID
+ )
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(3)
+
+ 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.first { it.id == destinationMessageId }
+ assertThat(destinationMessage.uid).isNotNull().startsWith(K9.LOCAL_UID_PREFIX)
+ assertThat(destinationMessage).isEqualTo(
+ originalMessage.copy(
+ id = destinationMessageId,
+ folderId = DESTINATION_FOLDER_ID,
+ uid = destinationMessage.uid,
+ deleted = 0,
+ 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
+ fun `move message not containing a message-id header`() {
+ val originalMessageId = sqliteDatabase.createMessage(
+ folderId = SOURCE_FOLDER_ID,
+ uid = "uid1",
+ subject = "Move me",
+ messageIdHeader = null
+ )
+ sqliteDatabase.createThread(messageId = originalMessageId)
+ val originalMessage = sqliteDatabase.readMessages().first()
+
+ val destinationMessageId = moveMessageOperations.moveMessage(
+ messageId = originalMessageId,
+ destinationFolderId = DESTINATION_FOLDER_ID
+ )
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(2)
+
+ val sourceMessage = messages.first { it.id == originalMessageId }
+ assertThat(sourceMessage.folderId).isEqualTo(SOURCE_FOLDER_ID)
+ assertThat(sourceMessage.uid).isEqualTo("uid1")
+ assertPlaceholderEntry(sourceMessage)
+
+ val destinationMessage = messages.first { it.id == destinationMessageId }
+ assertThat(destinationMessage.uid).isNotNull().startsWith(K9.LOCAL_UID_PREFIX)
+ assertThat(destinationMessage).isEqualTo(
+ originalMessage.copy(
+ id = destinationMessageId,
+ folderId = DESTINATION_FOLDER_ID,
+ uid = destinationMessage.uid,
+ deleted = 0,
+ 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()
+ }
+
+ private fun assertPlaceholderEntry(message: MessageEntry) {
+ assertThat(message.deleted).isEqualTo(1)
+ assertThat(message.empty).isEqualTo(0)
+ assertThat(message.read).isEqualTo(1)
+ assertThat(message.date).isNull()
+ assertThat(message.flags).isNull()
+ assertThat(message.senderList).isNull()
+ assertThat(message.toList).isNull()
+ assertThat(message.ccList).isNull()
+ assertThat(message.bccList).isNull()
+ assertThat(message.replyToList).isNull()
+ assertThat(message.attachmentCount).isNull()
+ assertThat(message.internalDate).isNull()
+ assertThat(message.previewType).isEqualTo("none")
+ assertThat(message.preview).isNull()
+ assertThat(message.mimeType).isNull()
+ assertThat(message.normalizedSubjectHash).isNull()
+ assertThat(message.flagged).isNull()
+ assertThat(message.answered).isNull()
+ assertThat(message.forwarded).isNull()
+ assertThat(message.messagePartId).isNull()
+ assertThat(message.encryptionType).isNull()
+ }
+
+ private fun fail(message: String): Nothing = junitFail(message) as Nothing
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt
new file mode 100644
index 0000000..d4eb74f
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt
@@ -0,0 +1,522 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.isEmpty
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFailure
+import assertk.assertions.isInstanceOf
+import assertk.assertions.isNotNull
+import assertk.assertions.isNull
+import assertk.assertions.isTrue
+import com.fsck.k9.Account.FolderMode
+import com.fsck.k9.mail.FolderClass
+import com.fsck.k9.mail.FolderType
+import com.fsck.k9.mailstore.FolderNotFoundException
+import com.fsck.k9.mailstore.MoreMessages
+import com.fsck.k9.mailstore.toDatabaseFolderType
+import com.fsck.k9.search.LocalSearch
+import com.fsck.k9.search.SearchSpecification
+import com.fsck.k9.storage.RobolectricTest
+import org.junit.Test
+
+class RetrieveFolderOperationsTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+ private val retrieveFolderOperations = RetrieveFolderOperations(lockableDatabase)
+
+ @Test
+ fun `get folder`() {
+ val folderId = sqliteDatabase.createFolder(
+ name = "Folder Name",
+ type = "inbox",
+ serverId = "uid",
+ isLocalOnly = false,
+ integrate = true,
+ inTopGroup = true,
+ displayClass = "FIRST_CLASS",
+ syncClass = "FIRST_CLASS",
+ notifyClass = "NO_CLASS",
+ pushClass = "NO_CLASS"
+ )
+
+ val result = retrieveFolderOperations.getFolder(folderId) { folder ->
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.name).isEqualTo("Folder Name")
+ assertThat(folder.type).isEqualTo(FolderType.INBOX)
+ assertThat(folder.serverId).isEqualTo("uid")
+ assertThat(folder.isLocalOnly).isEqualTo(false)
+ assertThat(folder.isIntegrate).isEqualTo(true)
+ assertThat(folder.isInTopGroup).isEqualTo(true)
+ assertThat(folder.displayClass).isEqualTo(FolderClass.FIRST_CLASS)
+ assertThat(folder.syncClass).isEqualTo(FolderClass.FIRST_CLASS)
+ assertThat(folder.notifyClass).isEqualTo(FolderClass.NO_CLASS)
+ assertThat(folder.pushClass).isEqualTo(FolderClass.NO_CLASS)
+ true
+ }
+
+ assertThat(result).isNotNull().isTrue()
+ }
+
+ @Test
+ fun `get local folder`() {
+ val name = "Local outbox"
+ val folderId = sqliteDatabase.createFolder(
+ name = name,
+ type = FolderType.OUTBOX.toDatabaseFolderType(),
+ serverId = null,
+ isLocalOnly = true,
+ integrate = true,
+ inTopGroup = true,
+ displayClass = FolderClass.FIRST_CLASS.name,
+ syncClass = null,
+ notifyClass = null,
+ pushClass = null
+ )
+
+ val result = retrieveFolderOperations.getFolder(folderId) { folder ->
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.name).isEqualTo(name)
+ assertThat(folder.type).isEqualTo(FolderType.OUTBOX)
+ assertThat(folder.serverId).isNull()
+ assertThat(folder.isLocalOnly).isEqualTo(true)
+ assertThat(folder.isIntegrate).isEqualTo(true)
+ assertThat(folder.isInTopGroup).isEqualTo(true)
+ assertThat(folder.displayClass).isEqualTo(FolderClass.FIRST_CLASS)
+ assertThat(folder.syncClass).isEqualTo(FolderClass.INHERITED)
+ assertThat(folder.notifyClass).isEqualTo(FolderClass.INHERITED)
+ assertThat(folder.pushClass).isEqualTo(FolderClass.SECOND_CLASS)
+ true
+ }
+
+ assertThat(result).isNotNull().isTrue()
+ }
+
+ @Test
+ fun `get non-existent folder should return null`() {
+ val result = retrieveFolderOperations.getFolder(1) { "failed" }
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `get folder by server ID`() {
+ val folderId = sqliteDatabase.createFolder(
+ name = "Folder Name",
+ type = "inbox",
+ serverId = "folder1",
+ isLocalOnly = false,
+ integrate = true,
+ inTopGroup = true,
+ displayClass = "FIRST_CLASS",
+ syncClass = "FIRST_CLASS",
+ notifyClass = "NO_CLASS",
+ pushClass = "NO_CLASS"
+ )
+
+ val result = retrieveFolderOperations.getFolder("folder1") { folder ->
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.name).isEqualTo("Folder Name")
+ assertThat(folder.type).isEqualTo(FolderType.INBOX)
+ assertThat(folder.serverId).isEqualTo("folder1")
+ assertThat(folder.isLocalOnly).isEqualTo(false)
+ assertThat(folder.isIntegrate).isEqualTo(true)
+ assertThat(folder.isInTopGroup).isEqualTo(true)
+ assertThat(folder.displayClass).isEqualTo(FolderClass.FIRST_CLASS)
+ assertThat(folder.syncClass).isEqualTo(FolderClass.FIRST_CLASS)
+ assertThat(folder.notifyClass).isEqualTo(FolderClass.NO_CLASS)
+ assertThat(folder.pushClass).isEqualTo(FolderClass.NO_CLASS)
+ true
+ }
+
+ assertThat(result).isNotNull().isTrue()
+ }
+
+ @Test
+ fun `get non-existent folder by server ID should return null`() {
+ val result = retrieveFolderOperations.getFolder("folder_id") { "failed" }
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `get folders should return all fields`() {
+ val folderId = sqliteDatabase.createFolder(
+ name = "Folder Name",
+ type = "inbox",
+ serverId = "uid",
+ isLocalOnly = false,
+ integrate = true,
+ inTopGroup = true,
+ displayClass = "FIRST_CLASS",
+ syncClass = "FIRST_CLASS",
+ notifyClass = "NO_CLASS",
+ pushClass = "NO_CLASS"
+ )
+
+ val result = retrieveFolderOperations.getFolders { folder ->
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.name).isEqualTo("Folder Name")
+ assertThat(folder.type).isEqualTo(FolderType.INBOX)
+ assertThat(folder.serverId).isEqualTo("uid")
+ assertThat(folder.isLocalOnly).isEqualTo(false)
+ assertThat(folder.isIntegrate).isEqualTo(true)
+ assertThat(folder.isInTopGroup).isEqualTo(true)
+ assertThat(folder.displayClass).isEqualTo(FolderClass.FIRST_CLASS)
+ assertThat(folder.syncClass).isEqualTo(FolderClass.FIRST_CLASS)
+ assertThat(folder.notifyClass).isEqualTo(FolderClass.NO_CLASS)
+ assertThat(folder.pushClass).isEqualTo(FolderClass.NO_CLASS)
+ true
+ }
+
+ assertThat(result).isEqualTo(listOf(true))
+ }
+
+ @Test
+ fun `get folders with excludeLocalOnly should only return remote folders`() {
+ val (folderId1, _, folderId3) = listOf(
+ sqliteDatabase.createFolder(name = "Folder 1", isLocalOnly = false),
+ sqliteDatabase.createFolder(name = "Folder 2", isLocalOnly = true),
+ sqliteDatabase.createFolder(name = "Folder 3", isLocalOnly = false)
+ )
+
+ val result = retrieveFolderOperations.getFolders(excludeLocalOnly = true) { folder ->
+ folder.id to folder.name
+ }
+
+ assertThat(result).isEqualTo(
+ listOf(
+ folderId1 to "Folder 1",
+ folderId3 to "Folder 3"
+ )
+ )
+ }
+
+ @Test
+ fun `get folders with empty store should return empty list`() {
+ val result = retrieveFolderOperations.getFolders { "failed" }
+
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `get first class display folders`() {
+ val (folderId1, folderId2, _) = listOf(
+ sqliteDatabase.createFolder(name = "Folder 1", displayClass = "FIRST_CLASS"),
+ sqliteDatabase.createFolder(name = "Folder 2", displayClass = "SECOND_CLASS"),
+ sqliteDatabase.createFolder(name = "Folder 3", displayClass = "NO_CLASS")
+ )
+
+ val result = retrieveFolderOperations.getDisplayFolders(
+ displayMode = FolderMode.FIRST_CLASS,
+ outboxFolderId = folderId2
+ ) { folder ->
+ folder.id to folder.name
+ }
+
+ assertThat(result).isEqualTo(listOf(folderId1 to "Folder 1"))
+ }
+
+ @Test
+ fun `get everything but second class display folders`() {
+ val (folderId1, _, folderId3) = listOf(
+ sqliteDatabase.createFolder(name = "Folder 1", displayClass = "FIRST_CLASS"),
+ sqliteDatabase.createFolder(name = "Folder 2", displayClass = "SECOND_CLASS"),
+ sqliteDatabase.createFolder(name = "Folder 3", displayClass = "NO_CLASS")
+ )
+
+ val result = retrieveFolderOperations.getDisplayFolders(
+ displayMode = FolderMode.NOT_SECOND_CLASS,
+ outboxFolderId = folderId1
+ ) { folder ->
+ folder.id to folder.name
+ }
+
+ assertThat(result).isEqualTo(
+ listOf(
+ folderId1 to "Folder 1",
+ folderId3 to "Folder 3"
+ )
+ )
+ }
+
+ @Test
+ fun `get first and second class display folders`() {
+ val (folderId1, folderId2, _) = listOf(
+ sqliteDatabase.createFolder(name = "Folder 1", displayClass = "FIRST_CLASS"),
+ sqliteDatabase.createFolder(name = "Folder 2", displayClass = "SECOND_CLASS"),
+ sqliteDatabase.createFolder(name = "Folder 3", displayClass = "NO_CLASS")
+ )
+
+ val result = retrieveFolderOperations.getDisplayFolders(
+ displayMode = FolderMode.FIRST_AND_SECOND_CLASS,
+ outboxFolderId = folderId1
+ ) { folder ->
+ folder.id to folder.name
+ }
+
+ assertThat(result).isEqualTo(
+ listOf(
+ folderId1 to "Folder 1",
+ folderId2 to "Folder 2"
+ )
+ )
+ }
+
+ @Test
+ fun `get display folders with message count`() {
+ val (folderId1, folderId2, folderId3, folderId4) = listOf(
+ sqliteDatabase.createFolder(name = "Folder 1", displayClass = "FIRST_CLASS"),
+ sqliteDatabase.createFolder(name = "Folder 2", displayClass = "SECOND_CLASS"),
+ sqliteDatabase.createFolder(name = "Folder 3", displayClass = "FIRST_CLASS"),
+ sqliteDatabase.createFolder(name = "Folder 4", displayClass = "NO_CLASS")
+ )
+ sqliteDatabase.createMessage(uid = "msg1", folderId = folderId1, read = true)
+ sqliteDatabase.createMessage(uid = "msg2", folderId = folderId2, read = true)
+ sqliteDatabase.createMessage(uid = "msg3", folderId = folderId3, read = true)
+ sqliteDatabase.createMessage(uid = "msg4", folderId = folderId3, read = false)
+ sqliteDatabase.createMessage(uid = "msg5", folderId = folderId3, read = false)
+
+ val result = retrieveFolderOperations.getDisplayFolders(
+ displayMode = FolderMode.ALL,
+ outboxFolderId = folderId2
+ ) { folder ->
+ Triple(folder.id, folder.name, folder.unreadMessageCount)
+ }
+
+ assertThat(result).hasSize(4)
+ assertThat(result.toSet()).isEqualTo(
+ setOf(
+ Triple(folderId1, "Folder 1", 0),
+ Triple(folderId2, "Folder 2", 1),
+ Triple(folderId3, "Folder 3", 2),
+ Triple(folderId4, "Folder 4", 0)
+ )
+ )
+ }
+
+ @Test
+ fun `get folder id`() {
+ val (_, folderId2) = listOf(
+ sqliteDatabase.createFolder(serverId = "folder1"),
+ sqliteDatabase.createFolder(serverId = "folder2")
+ )
+
+ val result = retrieveFolderOperations.getFolderId(folderServerId = "folder2")
+
+ assertThat(result).isEqualTo(folderId2)
+ }
+
+ @Test
+ fun `get folder id should return null if no folder was found`() {
+ val result = retrieveFolderOperations.getFolderId(folderServerId = "folderId")
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `get folder server id`() {
+ val (_, folderId2) = listOf(
+ sqliteDatabase.createFolder(serverId = "folder1"),
+ sqliteDatabase.createFolder(serverId = "folder2")
+ )
+
+ val result = retrieveFolderOperations.getFolderServerId(folderId2)
+
+ assertThat(result).isEqualTo("folder2")
+ }
+
+ @Test
+ fun `get folder server id should return null if no folder was found`() {
+ val result = retrieveFolderOperations.getFolderServerId(folderId = 1)
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `get message count from empty folder`() {
+ val folderId = sqliteDatabase.createFolder()
+
+ val result = retrieveFolderOperations.getMessageCount(folderId)
+
+ assertThat(result).isEqualTo(0)
+ }
+
+ @Test
+ fun `get message count from non-existent folder`() {
+ val result = retrieveFolderOperations.getMessageCount(23)
+
+ assertThat(result).isEqualTo(0)
+ }
+
+ @Test
+ fun `get message count from non-empty folder`() {
+ val folderId = sqliteDatabase.createFolder()
+ sqliteDatabase.createMessage(folderId = folderId)
+ sqliteDatabase.createMessage(folderId = folderId)
+
+ val result = retrieveFolderOperations.getMessageCount(folderId)
+
+ assertThat(result).isEqualTo(2)
+ }
+
+ @Test
+ fun `get unread message count from empty folder`() {
+ val folderId = sqliteDatabase.createFolder()
+
+ val result = retrieveFolderOperations.getUnreadMessageCount(folderId)
+
+ assertThat(result).isEqualTo(0)
+ }
+
+ @Test
+ fun `get unread message count from non-existent folder`() {
+ val result = retrieveFolderOperations.getUnreadMessageCount(23)
+
+ assertThat(result).isEqualTo(0)
+ }
+
+ @Test
+ fun `get unread message count from non-empty folder`() {
+ val folderId = sqliteDatabase.createFolder()
+ sqliteDatabase.createMessage(folderId = folderId, read = false)
+ sqliteDatabase.createMessage(folderId = folderId, read = false)
+ sqliteDatabase.createMessage(folderId = folderId, read = true)
+
+ val result = retrieveFolderOperations.getUnreadMessageCount(folderId)
+
+ assertThat(result).isEqualTo(2)
+ }
+
+ @Test
+ fun `get unread message count with condition from empty folder`() {
+ sqliteDatabase.createFolder(integrate = true)
+
+ val result = retrieveFolderOperations.getUnreadMessageCount(unifiedInboxConditions)
+
+ assertThat(result).isEqualTo(0)
+ }
+
+ @Test
+ fun `get unread message count with condition from non-existent folder`() {
+ val result = retrieveFolderOperations.getUnreadMessageCount(unifiedInboxConditions)
+
+ assertThat(result).isEqualTo(0)
+ }
+
+ @Test
+ fun `get unread message count with condition from non-empty folder`() {
+ val folderId1 = sqliteDatabase.createFolder(integrate = true)
+ sqliteDatabase.createMessage(folderId = folderId1, read = false)
+ sqliteDatabase.createMessage(folderId = folderId1, read = false)
+ sqliteDatabase.createMessage(folderId = folderId1, read = true)
+ val folderId2 = sqliteDatabase.createFolder(integrate = true)
+ sqliteDatabase.createMessage(folderId = folderId2, read = false)
+ sqliteDatabase.createMessage(folderId = folderId2, read = true)
+ val folderId3 = sqliteDatabase.createFolder(integrate = false)
+ sqliteDatabase.createMessage(folderId = folderId3, read = false)
+
+ val result = retrieveFolderOperations.getUnreadMessageCount(unifiedInboxConditions)
+
+ assertThat(result).isEqualTo(3)
+ }
+
+ @Test
+ fun `get unread message count without condition`() {
+ val folderId1 = sqliteDatabase.createFolder(integrate = true)
+ sqliteDatabase.createMessage(folderId = folderId1, read = false)
+ val folderId2 = sqliteDatabase.createFolder(integrate = false)
+ sqliteDatabase.createMessage(folderId = folderId2, read = false)
+ sqliteDatabase.createMessage(folderId = folderId2, read = true)
+
+ val result = retrieveFolderOperations.getUnreadMessageCount(conditions = null)
+
+ assertThat(result).isEqualTo(2)
+ }
+
+ @Test
+ fun `get starred message count with condition from empty folder`() {
+ sqliteDatabase.createFolder(integrate = true)
+
+ val result = retrieveFolderOperations.getStarredMessageCount(unifiedInboxConditions)
+
+ assertThat(result).isEqualTo(0)
+ }
+
+ @Test
+ fun `get starred message count with condition from non-existent folder`() {
+ val result = retrieveFolderOperations.getStarredMessageCount(unifiedInboxConditions)
+
+ assertThat(result).isEqualTo(0)
+ }
+
+ @Test
+ fun `get starred message count with condition from non-empty folder`() {
+ val folderId1 = sqliteDatabase.createFolder(integrate = true)
+ sqliteDatabase.createMessage(folderId = folderId1, flagged = false)
+ sqliteDatabase.createMessage(folderId = folderId1, flagged = true)
+ val folderId2 = sqliteDatabase.createFolder(integrate = true)
+ sqliteDatabase.createMessage(folderId = folderId2, flagged = true)
+ sqliteDatabase.createMessage(folderId = folderId2, flagged = true)
+ sqliteDatabase.createMessage(folderId = folderId2, flagged = false)
+ val folderId3 = sqliteDatabase.createFolder(integrate = false)
+ sqliteDatabase.createMessage(folderId = folderId3, flagged = true)
+
+ val result = retrieveFolderOperations.getStarredMessageCount(unifiedInboxConditions)
+
+ assertThat(result).isEqualTo(3)
+ }
+
+ @Test
+ fun `get starred message count without condition`() {
+ val folderId1 = sqliteDatabase.createFolder(integrate = true)
+ sqliteDatabase.createMessage(folderId = folderId1, flagged = true)
+ val folderId2 = sqliteDatabase.createFolder(integrate = false)
+ sqliteDatabase.createMessage(folderId = folderId2, flagged = true)
+ sqliteDatabase.createMessage(folderId = folderId2, flagged = false)
+
+ val result = retrieveFolderOperations.getStarredMessageCount(conditions = null)
+
+ assertThat(result).isEqualTo(2)
+ }
+
+ @Test
+ fun `get 'more messages' value from non-existent folder`() {
+ assertThat {
+ retrieveFolderOperations.hasMoreMessages(23)
+ }.isFailure()
+ .isInstanceOf(FolderNotFoundException::class)
+ .transform { it.folderId }.isEqualTo(23)
+ }
+
+ @Test
+ fun `get 'more messages' value from folder with value 'unknown'`() {
+ val folderId = sqliteDatabase.createFolder(moreMessages = "unknown")
+
+ val result = retrieveFolderOperations.hasMoreMessages(folderId)
+
+ assertThat(result).isEqualTo(MoreMessages.UNKNOWN)
+ }
+
+ @Test
+ fun `get 'more messages' value from folder with value 'false'`() {
+ val folderId = sqliteDatabase.createFolder(moreMessages = "false")
+
+ val result = retrieveFolderOperations.hasMoreMessages(folderId)
+
+ assertThat(result).isEqualTo(MoreMessages.FALSE)
+ }
+
+ @Test
+ fun `get 'more messages' value from folder with value 'true'`() {
+ val folderId = sqliteDatabase.createFolder(moreMessages = "true")
+
+ val result = retrieveFolderOperations.hasMoreMessages(folderId)
+
+ assertThat(result).isEqualTo(MoreMessages.TRUE)
+ }
+
+ private val unifiedInboxConditions = LocalSearch().apply {
+ and(SearchSpecification.SearchField.INTEGRATE, "1", SearchSpecification.Attribute.EQUALS)
+ }.conditions
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt
new file mode 100644
index 0000000..c009d68
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt
@@ -0,0 +1,491 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.containsExactly
+import assertk.assertions.isEmpty
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFalse
+import assertk.assertions.isTrue
+import com.fsck.k9.mail.Address
+import com.fsck.k9.mailstore.DatabasePreviewType
+import com.fsck.k9.mailstore.MessageMapper
+import com.fsck.k9.message.extractors.PreviewResult.PreviewType
+import com.fsck.k9.storage.RobolectricTest
+import org.junit.Test
+
+class RetrieveMessageListOperationsTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+ private val retrieveMessageListOperations = RetrieveMessageListOperations(lockableDatabase)
+
+ @Test
+ fun `getMessages() on empty folder`() {
+ val folderId = sqliteDatabase.createFolder()
+
+ val result = getMessagesFromFolder(folderId) { "unexpected" }
+
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `getMessages() with only a deleted message`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId = sqliteDatabase.createMessage(folderId, uid = "uid1", deleted = true)
+ sqliteDatabase.createThread(messageId)
+
+ val result = getMessagesFromFolder(folderId) { "unexpected" }
+
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `getMessages() with single message`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId = sqliteDatabase.createMessage(
+ folderId,
+ uid = "uid1",
+ subject = "subject",
+ date = 123L,
+ senderList = Address.pack(Address.parse("from@domain.example")),
+ toList = Address.pack(Address.parse("to@domain.example")),
+ ccList = Address.pack(Address.parse("cc1@domain.example, cc2@domain.example")),
+ attachmentCount = 1,
+ internalDate = 456L,
+ previewType = DatabasePreviewType.TEXT,
+ preview = "preview",
+ read = true,
+ flagged = true,
+ answered = true,
+ forwarded = true
+ )
+ val threadId = sqliteDatabase.createThread(messageId)
+
+ val result = getMessagesFromFolder(folderId) { message ->
+ assertThat(message.id).isEqualTo(messageId)
+ assertThat(message.messageServerId).isEqualTo("uid1")
+ assertThat(message.folderId).isEqualTo(folderId)
+ assertThat(message.fromAddresses).containsExactly(Address("from@domain.example"))
+ assertThat(message.toAddresses).containsExactly(Address("to@domain.example"))
+ assertThat(message.ccAddresses).containsExactly(Address("cc1@domain.example"), Address("cc2@domain.example"))
+ assertThat(message.messageDate).isEqualTo(123L)
+ assertThat(message.internalDate).isEqualTo(456L)
+ assertThat(message.subject).isEqualTo("subject")
+ assertThat(message.preview.previewType).isEqualTo(PreviewType.TEXT)
+ assertThat(message.preview.previewText).isEqualTo("preview")
+ assertThat(message.isRead).isTrue()
+ assertThat(message.isStarred).isTrue()
+ assertThat(message.isAnswered).isTrue()
+ assertThat(message.isForwarded).isTrue()
+ assertThat(message.hasAttachments).isTrue()
+ assertThat(message.threadRoot).isEqualTo(threadId)
+ assertThat(message.threadCount).isEqualTo(0)
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getMessages() with folder containing an empty message`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, empty = true)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message -> message.id }
+
+ assertThat(result).containsExactly(messageId2)
+ }
+
+ @Test
+ fun `getMessages() with folder containing a deleted message`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, deleted = true)
+ sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+ sqliteDatabase.createThread(messageId2)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message -> message.id }
+
+ assertThat(result).containsExactly(messageId2)
+ }
+
+ @Test
+ fun `getMessages() selecting only unread messages`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = false)
+ sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = true)
+ sqliteDatabase.createThread(messageId2)
+ val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3", read = false)
+ sqliteDatabase.createThread(messageId3)
+
+ val result = retrieveMessageListOperations.getThreadedMessages(
+ selection = "folder_id = ? AND read = 0",
+ selectionArgs = arrayOf(folderId.toString()),
+ sortOrder = "date DESC, id DESC"
+ ) { message ->
+ message.id
+ }
+
+ assertThat(result).containsExactly(messageId3, messageId1)
+ }
+
+ @Test
+ fun `getThreadedMessages() on empty folder`() {
+ val folderId = sqliteDatabase.createFolder()
+
+ val result = getThreadedMessagesFromFolder(folderId) { "unexpected" }
+
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `getThreadedMessages() with only a deleted message`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId = sqliteDatabase.createMessage(folderId, uid = "uid1", deleted = true)
+ sqliteDatabase.createThread(messageId)
+
+ val result = getThreadedMessagesFromFolder(folderId) { "unexpected" }
+
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `getThreadedMessages() with single message`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId = sqliteDatabase.createMessage(
+ folderId,
+ uid = "uid1",
+ subject = "subject",
+ date = 123L,
+ senderList = Address.pack(Address.parse("from@domain.example")),
+ toList = Address.pack(Address.parse("to@domain.example")),
+ ccList = Address.pack(Address.parse("cc1@domain.example, cc2@domain.example")),
+ attachmentCount = 1,
+ internalDate = 456L,
+ previewType = DatabasePreviewType.TEXT,
+ preview = "preview",
+ read = true,
+ flagged = true,
+ answered = true,
+ forwarded = true
+ )
+ val threadId = sqliteDatabase.createThread(messageId)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.id).isEqualTo(messageId)
+ assertThat(message.messageServerId).isEqualTo("uid1")
+ assertThat(message.folderId).isEqualTo(folderId)
+ assertThat(message.fromAddresses).containsExactly(Address("from@domain.example"))
+ assertThat(message.toAddresses).containsExactly(Address("to@domain.example"))
+ assertThat(message.ccAddresses).containsExactly(Address("cc1@domain.example"), Address("cc2@domain.example"))
+ assertThat(message.messageDate).isEqualTo(123L)
+ assertThat(message.internalDate).isEqualTo(456L)
+ assertThat(message.subject).isEqualTo("subject")
+ assertThat(message.preview.previewType).isEqualTo(PreviewType.TEXT)
+ assertThat(message.preview.previewText).isEqualTo("preview")
+ assertThat(message.isRead).isTrue()
+ assertThat(message.isStarred).isTrue()
+ assertThat(message.isAnswered).isTrue()
+ assertThat(message.isForwarded).isTrue()
+ assertThat(message.hasAttachments).isTrue()
+ assertThat(message.threadRoot).isEqualTo(threadId)
+ assertThat(message.threadCount).isEqualTo(1)
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() with thread containing an empty message`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, empty = true)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.id).isEqualTo(messageId2)
+ assertThat(message.messageServerId).isEqualTo("uid2")
+ assertThat(message.threadRoot).isEqualTo(threadId1)
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should return latest message in thread`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(
+ folderId,
+ uid = "uid1",
+ date = 1000L,
+ internalDate = 1001L
+ )
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(
+ folderId,
+ uid = "uid2",
+ date = 2000L,
+ internalDate = 2001L
+ )
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.id).isEqualTo(messageId2)
+ assertThat(message.messageDate).isEqualTo(2000L)
+ assertThat(message.internalDate).isEqualTo(2001L)
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should return 'unread' when at least one message in thread is marked as unread`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = true)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = false)
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.isRead).isFalse()
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should return 'read' when all messages in thread are marked as read`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = true)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = true)
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.isRead).isTrue()
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should return 'starred' when at least one message in thread is marked as starred`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", flagged = false)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", flagged = true)
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.isStarred).isTrue()
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should return 'not starred' when all messages in thread are not marked as starred`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", flagged = false)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", flagged = false)
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.isStarred).isFalse()
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should return 'not answered' when not all messages in thread are marked as answered`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", answered = false)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", answered = true)
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.isAnswered).isFalse()
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should return 'answered' when all messages in thread are marked as answered`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", answered = true)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", answered = true)
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.isAnswered).isTrue()
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should return 'not forwarded' when not all messages in thread are marked as forwarded`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", forwarded = true)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", forwarded = false)
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.isForwarded).isFalse()
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should return 'forwarded' when all messages in thread are marked as forwarded`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", forwarded = true)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", forwarded = true)
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.isForwarded).isTrue()
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should return 'has attachment' when at least one message in thread contains an attachment`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", attachmentCount = 1)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", attachmentCount = 0)
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.hasAttachments).isTrue()
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should return 'has no attachment' when no message in thread contains an attachment`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", attachmentCount = 0)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", attachmentCount = 0)
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.hasAttachments).isFalse()
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() with 3 messages in thread`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1")
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+ val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3")
+ sqliteDatabase.createThread(messageId3, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.threadCount).isEqualTo(3)
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThreadedMessages() should not include empty messages in thread count`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", empty = true)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+ val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3")
+ sqliteDatabase.createThread(messageId3, root = threadId1)
+
+ val result = getThreadedMessagesFromFolder(folderId) { message ->
+ assertThat(message.threadCount).isEqualTo(2)
+ "OK"
+ }
+
+ assertThat(result).containsExactly("OK")
+ }
+
+ @Test
+ fun `getThread() with empty message as thread root`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, empty = true)
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+ sqliteDatabase.createThread(messageId2, root = threadId1)
+ val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3")
+ sqliteDatabase.createThread(messageId3, root = threadId1)
+
+ val result = retrieveMessageListOperations.getThread(threadId = threadId1, sortOrder = "date DESC") { it.id }
+
+ assertThat(result).containsExactly(messageId2, messageId3)
+ }
+
+ @Test
+ fun `getThread() should only return messages in thread`() {
+ val folderId = sqliteDatabase.createFolder()
+ val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1")
+ sqliteDatabase.createThread(messageId1)
+ val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+ val threadId2 = sqliteDatabase.createThread(messageId2)
+ val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3")
+ sqliteDatabase.createThread(messageId3, root = threadId2)
+
+ val result = retrieveMessageListOperations.getThread(threadId = threadId2, sortOrder = "date DESC") { it.id }
+
+ assertThat(result).containsExactly(messageId2, messageId3)
+ }
+
+ private fun <T> getMessagesFromFolder(folderId: Long, mapper: MessageMapper<T?>): List<T> {
+ return retrieveMessageListOperations.getMessages(
+ selection = "folder_id = ?",
+ selectionArgs = arrayOf(folderId.toString()),
+ sortOrder = "date DESC, id DESC",
+ mapper
+ )
+ }
+
+ private fun <T> getThreadedMessagesFromFolder(folderId: Long, mapper: MessageMapper<T?>): List<T> {
+ return retrieveMessageListOperations.getThreadedMessages(
+ selection = "folder_id = ?",
+ selectionArgs = arrayOf(folderId.toString()),
+ sortOrder = "date DESC, id DESC",
+ mapper
+ )
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt
new file mode 100644
index 0000000..e933fa6
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt
@@ -0,0 +1,222 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.isEmpty
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFalse
+import assertk.assertions.isNull
+import assertk.assertions.isTrue
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.mail.Header
+import com.fsck.k9.mail.crlf
+import com.fsck.k9.storage.RobolectricTest
+import java.util.Date
+import org.junit.Test
+
+class RetrieveMessageOperationsTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+ private val retrieveMessageOperations = RetrieveMessageOperations(lockableDatabase)
+
+ @Test
+ fun `get message server id of non-existent message`() {
+ val messageServerId = retrieveMessageOperations.getMessageServerId(42)
+
+ assertThat(messageServerId).isNull()
+ }
+
+ @Test
+ fun `get message server id`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1")
+ val messageId = sqliteDatabase.createMessage(folderId = 1, uid = "uid2")
+
+ val messageServerId = retrieveMessageOperations.getMessageServerId(messageId)
+
+ assertThat(messageServerId).isEqualTo("uid2")
+ }
+
+ @Test
+ fun `get message server ids`() {
+ val messageId1 = sqliteDatabase.createMessage(folderId = 1, uid = "uid1")
+ val messageId2 = sqliteDatabase.createMessage(folderId = 1, uid = "uid2")
+ val messageId3 = sqliteDatabase.createMessage(folderId = 1, uid = "uid3")
+ val messageId4 = sqliteDatabase.createMessage(folderId = 1, uid = "uid4")
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid5")
+ val messageIds = listOf(messageId1, messageId2, messageId3, messageId4)
+
+ val databaseIdToServerIdMapping = retrieveMessageOperations.getMessageServerIds(messageIds)
+
+ assertThat(databaseIdToServerIdMapping).isEqualTo(
+ mapOf(
+ messageId1 to "uid1",
+ messageId2 to "uid2",
+ messageId3 to "uid3",
+ messageId4 to "uid4"
+ )
+ )
+ }
+
+ @Test
+ fun `get all message server ids`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1")
+ sqliteDatabase.createMessage(folderId = 1, uid = "K9LOCAL:1")
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid3")
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid4")
+
+ val messageServerIds = retrieveMessageOperations.getMessageServerIds(folderId = 1)
+
+ assertThat(messageServerIds).isEqualTo(setOf("uid1", "uid3", "uid4"))
+ }
+
+ @Test
+ fun `check if message is present`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1")
+
+ val result = retrieveMessageOperations.isMessagePresent(folderId = 1, messageServerId = "uid1")
+
+ assertThat(result).isTrue()
+ }
+
+ @Test
+ fun `check if non-existent message is present`() {
+ val result = retrieveMessageOperations.isMessagePresent(folderId = 1, messageServerId = "uid1")
+
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `get message flags`() {
+ sqliteDatabase.createMessage(
+ folderId = 1,
+ uid = "uid1",
+ flags = "DRAFT,RECENT,X_DESTROYED,X_SEND_FAILED,X_SEND_IN_PROGRESS,X_DOWNLOADED_FULL," +
+ "X_DOWNLOADED_PARTIAL,X_REMOTE_COPY_STARTED,X_MIGRATED_FROM_V50,X_DRAFT_OPENPGP_INLINE," +
+ "X_SUBJECT_DECRYPTED",
+ deleted = true,
+ read = true,
+ flagged = true,
+ answered = true,
+ forwarded = true
+ )
+
+ val flags = retrieveMessageOperations.getMessageFlags(folderId = 1, messageServerId = "uid1")
+
+ assertThat(flags).isEqualTo(
+ setOf(
+ Flag.DELETED,
+ Flag.SEEN,
+ Flag.ANSWERED,
+ Flag.FLAGGED,
+ Flag.DRAFT,
+ Flag.RECENT,
+ Flag.FORWARDED,
+ Flag.X_DESTROYED,
+ Flag.X_SEND_FAILED,
+ Flag.X_SEND_IN_PROGRESS,
+ Flag.X_DOWNLOADED_FULL,
+ Flag.X_DOWNLOADED_PARTIAL,
+ Flag.X_REMOTE_COPY_STARTED,
+ Flag.X_MIGRATED_FROM_V50,
+ Flag.X_DRAFT_OPENPGP_INLINE,
+ Flag.X_SUBJECT_DECRYPTED
+ )
+ )
+ }
+
+ @Test
+ fun `get message flags without any flags set`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", flags = "")
+
+ val flags = retrieveMessageOperations.getMessageFlags(folderId = 1, messageServerId = "uid1")
+
+ assertThat(flags).isEmpty()
+ }
+
+ @Test
+ fun `get all message server ids and dates`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", date = 23)
+ sqliteDatabase.createMessage(folderId = 1, uid = "K9LOCAL:1")
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid3", date = 42)
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid4", deleted = true)
+
+ val result = retrieveMessageOperations.getAllMessagesAndEffectiveDates(folderId = 1)
+
+ assertThat(result).isEqualTo(
+ mapOf(
+ "uid1" to 23L,
+ "uid3" to 42L
+ )
+ )
+ }
+
+ @Test
+ fun `get headers`() {
+ val messagePartId = sqliteDatabase.createMessagePart(
+ header = """
+ From: <alice@domain.example>
+ To: Bob <bob@domain.example>
+ Date: Thu, 01 Apr 2021 01:23:45 +0200
+ Subject: Test
+ Message-Id: <20210401012345.123456789A@domain.example>
+ """.trimIndent().crlf()
+ )
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", messagePartId = messagePartId)
+
+ val headers = retrieveMessageOperations.getHeaders(folderId = 1, messageServerId = "uid1")
+
+ assertThat(headers).isEqualTo(
+ listOf(
+ Header("From", "<alice@domain.example>"),
+ Header("To", "Bob <bob@domain.example>"),
+ Header("Date", "Thu, 01 Apr 2021 01:23:45 +0200"),
+ Header("Subject", "Test"),
+ Header("Message-Id", "<20210401012345.123456789A@domain.example>")
+ )
+ )
+ }
+
+ @Test
+ fun `get some headers`() {
+ val messagePartId = sqliteDatabase.createMessagePart(
+ header = """
+ From: <alice@domain.example>
+ To: Bob <bob@domain.example>
+ Date: Thu, 01 Apr 2021 01:23:45 +0200
+ Subject: Test
+ Message-Id: <20210401012345.123456789A@domain.example>
+ """.trimIndent().crlf()
+ )
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", messagePartId = messagePartId)
+
+ val headers = retrieveMessageOperations.getHeaders(
+ folderId = 1,
+ messageServerId = "uid1",
+ headerNames = setOf("from", "to", "message-id")
+ )
+
+ assertThat(headers).isEqualTo(
+ listOf(
+ Header("From", "<alice@domain.example>"),
+ Header("To", "Bob <bob@domain.example>"),
+ Header("Message-Id", "<20210401012345.123456789A@domain.example>")
+ )
+ )
+ }
+
+ @Test
+ fun `get oldest message date`() {
+ sqliteDatabase.createMessage(folderId = 1, date = 42)
+ sqliteDatabase.createMessage(folderId = 1, date = 23)
+
+ val oldestMessageDate = retrieveMessageOperations.getOldestMessageDate(folderId = 1)
+
+ assertThat(oldestMessageDate).isEqualTo(Date(23))
+ }
+
+ @Test
+ fun `get oldest message date without any messages in the store`() {
+ val oldestMessageDate = retrieveMessageOperations.getOldestMessageDate(folderId = 1)
+
+ assertThat(oldestMessageDate).isNull()
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/SaveMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/SaveMessageOperationsTest.kt
new file mode 100644
index 0000000..b5b3f3e
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/SaveMessageOperationsTest.kt
@@ -0,0 +1,556 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNotNull
+import assertk.assertions.isNull
+import assertk.assertions.startsWith
+import com.fsck.k9.K9
+import com.fsck.k9.mail.Address
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.mail.Message
+import com.fsck.k9.mail.MessageDownloadState
+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 java.io.ByteArrayOutputStream
+import java.util.Stack
+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 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")
+ }
+
+ @Test
+ fun `save message with server ID already existing in MessageStore should replace that message`() {
+ val existingMessageData = buildMessage {
+ multipart("alternative") {
+ bodyPart("text/plain") {
+ textBody("plain")
+ }
+ bodyPart("text/html") {
+ textBody("html")
+ }
+ }
+ }.toSaveMessageData()
+ saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", existingMessageData)
+ val messageData = buildMessage {
+ textBody("new")
+ }.toSaveMessageData()
+
+ saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData)
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(1)
+
+ 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(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(3)
+ assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("new")
+ 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()
+ val message = messages.first()
+ assertThat(thread.root).isEqualTo(thread.id)
+ assertThat(thread.parent).isNull()
+ assertThat(thread.messageId).isEqualTo(message.id)
+ }
+
+ @Test
+ fun `save local message`() {
+ val messageData = buildMessage {
+ textBody("local")
+ }.toSaveMessageData(
+ subject = "Provided subject",
+ date = 1618191720000L,
+ internalDate = 1618191720000L,
+ previewResult = PreviewResult.text("Preview")
+ )
+
+ val newMessageId = saveMessageOperations.saveLocalMessage(folderId = 1, messageData, existingMessageId = null)
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(1)
+
+ val message = messages.first()
+ with(message) {
+ assertThat(id).isEqualTo(newMessageId)
+ assertThat(deleted).isEqualTo(0)
+ assertThat(folderId).isEqualTo(1)
+ assertThat(uid).isNotNull().startsWith(K9.LOCAL_UID_PREFIX)
+ assertThat(subject).isEqualTo("Provided subject")
+ assertThat(date).isEqualTo(1618191720000L)
+ assertThat(internalDate).isEqualTo(1618191720000L)
+ assertThat(flags).isEqualTo("X_DOWNLOADED_FULL")
+ assertThat(senderList).isEqualTo("")
+ assertThat(toList).isEqualTo("")
+ assertThat(ccList).isEqualTo("")
+ assertThat(bccList).isEqualTo("")
+ assertThat(replyToList).isEqualTo("")
+ assertThat(attachmentCount).isEqualTo(0)
+ assertThat(messageId).isNull()
+ assertThat(previewType).isEqualTo("text")
+ assertThat(preview).isEqualTo("Preview")
+ assertThat(mimeType).isEqualTo("text/plain")
+ assertThat(empty).isEqualTo(0)
+ assertThat(read).isEqualTo(0)
+ assertThat(flagged).isEqualTo(0)
+ assertThat(answered).isEqualTo(0)
+ assertThat(forwarded).isEqualTo(0)
+ 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(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("local")
+ 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()
+ assertThat(thread.root).isEqualTo(thread.id)
+ assertThat(thread.parent).isNull()
+ assertThat(thread.messageId).isEqualTo(message.id)
+ }
+
+ @Test
+ fun `replace local message`() {
+ val existingMessageData = buildMessage {
+ multipart("alternative") {
+ bodyPart("text/plain") {
+ textBody("plain")
+ }
+ bodyPart("text/html") {
+ textBody("html")
+ }
+ }
+ }.toSaveMessageData()
+ val existingMessageId = saveMessageOperations.saveLocalMessage(
+ folderId = 1,
+ existingMessageData,
+ existingMessageId = null
+ )
+ val messageData = buildMessage {
+ textBody("new")
+ }.toSaveMessageData()
+
+ val newMessageId = saveMessageOperations.saveLocalMessage(folderId = 1, messageData, existingMessageId)
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(1)
+
+ assertThat(messages.first().id).isEqualTo(newMessageId)
+
+ 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(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(3)
+ assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("new")
+ 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()
+ val message = messages.first()
+ assertThat(thread.root).isEqualTo(thread.id)
+ assertThat(thread.parent).isNull()
+ assertThat(thread.messageId).isEqualTo(message.id)
+ }
+
+ private fun Message.toSaveMessageData(
+ subject: String? = getSubject(),
+ date: Long = sentDate?.time ?: System.currentTimeMillis(),
+ internalDate: Long = date,
+ downloadState: MessageDownloadState = getDownloadState(),
+ attachmentCount: Int = 0,
+ previewResult: PreviewResult = PreviewResult.none(),
+ textForSearchIndex: String? = null,
+ encryptionType: String? = null
+ ): SaveMessageData {
+ return SaveMessageData(
+ message = this,
+ subject,
+ date,
+ internalDate,
+ downloadState,
+ attachmentCount,
+ previewResult,
+ textForSearchIndex,
+ encryptionType
+ )
+ }
+
+ private fun Message.getDownloadState(): MessageDownloadState {
+ if (body == null) return MessageDownloadState.ENVELOPE
+
+ val stack = Stack<Part>()
+ stack.push(this)
+
+ while (stack.isNotEmpty()) {
+ val part = stack.pop()
+ when (val body = part.body) {
+ null -> return MessageDownloadState.PARTIAL
+ is Multipart -> {
+ for (i in 0 until body.count) {
+ stack.push(body.getBodyPart(i))
+ }
+ }
+ }
+ }
+
+ return MessageDownloadState.FULL
+ }
+
+ private fun Message.header(): String {
+ val outputStream = ByteArrayOutputStream()
+ writeHeaderTo(outputStream)
+ return outputStream.toString("UTF-8")
+ }
+
+ private fun Message.boundary(): String? = (body as Multipart).boundary
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadDatabaseHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadDatabaseHelpers.kt
new file mode 100644
index 0000000..31831f1
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadDatabaseHelpers.kt
@@ -0,0 +1,40 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import app.k9mail.core.android.common.database.getLongOrNull
+import app.k9mail.core.android.common.database.map
+
+fun SQLiteDatabase.createThread(
+ messageId: Long,
+ root: Long? = null,
+ parent: Long? = null
+): Long {
+ val values = ContentValues().apply {
+ put("message_id", messageId)
+ put("root", root)
+ put("parent", parent)
+ }
+
+ return insert("threads", null, values)
+}
+
+fun SQLiteDatabase.readThreads(): List<ThreadEntry> {
+ return rawQuery("SELECT * FROM threads", null).use { cursor ->
+ cursor.map {
+ ThreadEntry(
+ id = cursor.getLongOrNull("id"),
+ messageId = cursor.getLongOrNull("message_id"),
+ root = cursor.getLongOrNull("root"),
+ parent = cursor.getLongOrNull("parent")
+ )
+ }
+ }
+}
+
+data class ThreadEntry(
+ val id: Long?,
+ val messageId: Long?,
+ val root: Long?,
+ val parent: Long?
+)
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadMessageOperationsTest.kt
new file mode 100644
index 0000000..658cfb6
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadMessageOperationsTest.kt
@@ -0,0 +1,266 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.isEmpty
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNull
+import com.fsck.k9.storage.RobolectricTest
+import org.junit.Test
+
+class ThreadMessageOperationsTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val threadMessageOperations = ThreadMessageOperations()
+
+ @Test
+ fun `insert message without existing thread`() {
+ val threadInfo = threadMessageOperations.doMessageThreading(
+ sqliteDatabase,
+ folderId = 1,
+ ThreadHeaders(messageIdHeader = "<msg001@domain.example>", inReplyToHeader = null, referencesHeader = null)
+ )
+
+ assertThat(threadInfo).isNull()
+
+ assertThat(sqliteDatabase.readThreads()).isEmpty()
+ }
+
+ @Test
+ fun `insert message with in-reply-to header without existing thread`() {
+ val threadInfo = threadMessageOperations.doMessageThreading(
+ sqliteDatabase,
+ folderId = 1,
+ ThreadHeaders(
+ messageIdHeader = "<msg002@domain.example>",
+ inReplyToHeader = "<msg001@domain.example>",
+ referencesHeader = null
+ )
+ )
+
+ val threads = sqliteDatabase.readThreads()
+ assertThat(threads).hasSize(1)
+
+ val rootThread = threads.first { it.id == it.root }
+ assertThat(rootThread.parent).isNull()
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(1)
+
+ val emptyMessage = messages.first()
+ assertThat(emptyMessage.id).isEqualTo(rootThread.messageId)
+ assertThat(emptyMessage.empty).isEqualTo(1)
+ assertThat(emptyMessage.messageId).isEqualTo("<msg001@domain.example>")
+
+ assertThat(threadInfo).isEqualTo(
+ ThreadInfo(
+ threadId = null,
+ messageId = null,
+ rootId = rootThread.id!!,
+ parentId = rootThread.id
+ )
+ )
+ }
+
+ @Test
+ fun `insert message with in-reply-to header into existing thread`() {
+ val messageId = sqliteDatabase.createMessage(
+ folderId = 1,
+ empty = false,
+ messageIdHeader = "<msg001@domain.example>"
+ )
+ val threadId = sqliteDatabase.createThread(messageId)
+
+ val threadInfo = threadMessageOperations.doMessageThreading(
+ sqliteDatabase,
+ folderId = 1,
+ ThreadHeaders(
+ messageIdHeader = "<msg002@domain.example>",
+ inReplyToHeader = "<msg001@domain.example>",
+ referencesHeader = null
+ )
+ )
+
+ val threads = sqliteDatabase.readThreads()
+ assertThat(threads).hasSize(1)
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(1)
+
+ assertThat(threadInfo).isEqualTo(
+ ThreadInfo(
+ threadId = null,
+ messageId = null,
+ rootId = threadId,
+ parentId = threadId
+ )
+ )
+ }
+
+ @Test
+ fun `insert message into existing thread as thread root`() {
+ val messageId1 = sqliteDatabase.createMessage(
+ folderId = 1,
+ empty = true,
+ messageIdHeader = "<msg001@domain.example>"
+ )
+ val messageId2 = sqliteDatabase.createMessage(
+ folderId = 1,
+ empty = false,
+ messageIdHeader = "<msg002@domain.example>"
+ )
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ sqliteDatabase.createThread(messageId2, root = threadId1, parent = threadId1)
+
+ val threadInfo = threadMessageOperations.doMessageThreading(
+ sqliteDatabase,
+ folderId = 1,
+ ThreadHeaders(
+ messageIdHeader = "<msg001@domain.example>",
+ inReplyToHeader = null,
+ referencesHeader = null
+ )
+ )
+
+ val threads = sqliteDatabase.readThreads()
+ assertThat(threads).hasSize(2)
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(2)
+
+ assertThat(threadInfo).isEqualTo(
+ ThreadInfo(
+ threadId = threadId1,
+ messageId = messageId1,
+ rootId = threadId1,
+ parentId = null
+ )
+ )
+ }
+
+ @Test
+ fun `insert message into existing thread changing the thread root`() {
+ val messageId1 = sqliteDatabase.createMessage(
+ folderId = 1,
+ messageIdHeader = "<msg001@domain.example>",
+ empty = true
+ )
+ val messageId2 = sqliteDatabase.createMessage(
+ folderId = 1,
+ messageIdHeader = "<msg002@domain.example>",
+ empty = true
+ )
+ val messageId3 = sqliteDatabase.createMessage(
+ folderId = 1,
+ messageIdHeader = "<msg003@domain.example>",
+ empty = false
+ )
+ val threadId1 = sqliteDatabase.createThread(messageId1)
+ val threadId2 = sqliteDatabase.createThread(messageId2, root = threadId1, parent = threadId1)
+ val threadId3 = sqliteDatabase.createThread(messageId3, root = threadId1, parent = threadId2)
+
+ val threadInfo = threadMessageOperations.doMessageThreading(
+ sqliteDatabase,
+ folderId = 1,
+ ThreadHeaders(
+ messageIdHeader = "<msg002@domain.example>",
+ inReplyToHeader = "<msg001@domain.example>",
+ referencesHeader = "<msg000@domain.example> <msg001@domain.example>"
+ )
+ )
+
+ val threads = sqliteDatabase.readThreads()
+ assertThat(threads).hasSize(4)
+
+ val newRootThread = threads.first { it.id !in setOf(threadId1, threadId2, threadId3) }
+ assertThat(newRootThread.root).isEqualTo(newRootThread.id)
+ assertThat(newRootThread.parent).isNull()
+
+ for (thread in threads) {
+ assertThat(thread.root).isEqualTo(newRootThread.id)
+ }
+
+ assertThat(threadInfo).isEqualTo(
+ ThreadInfo(
+ threadId = threadId2,
+ messageId = messageId2,
+ rootId = newRootThread.id!!,
+ parentId = threadId1
+ )
+ )
+ }
+
+ @Test
+ fun `merge two existing threads`() {
+ val messageIdB = sqliteDatabase.createMessage(
+ folderId = 1,
+ messageIdHeader = "<msgB@domain.example>",
+ empty = true
+ )
+ val messageIdC = sqliteDatabase.createMessage(
+ folderId = 1,
+ messageIdHeader = "<msgC@domain.example>",
+ empty = false
+ )
+ val messageIdD = sqliteDatabase.createMessage(
+ folderId = 1,
+ messageIdHeader = "<msgD@domain.example>",
+ empty = true
+ )
+ val messageIdE = sqliteDatabase.createMessage(
+ folderId = 1,
+ messageIdHeader = "<msgE@domain.example>",
+ empty = false
+ )
+ val threadIdB = sqliteDatabase.createThread(messageIdB)
+ val threadIdC = sqliteDatabase.createThread(messageIdC, root = threadIdB, parent = threadIdB)
+ val threadIdD = sqliteDatabase.createThread(messageIdD)
+ val threadIdE = sqliteDatabase.createThread(messageIdE, root = threadIdD, parent = threadIdD)
+
+ val threadInfo = threadMessageOperations.doMessageThreading(
+ sqliteDatabase,
+ folderId = 1,
+ ThreadHeaders(
+ messageIdHeader = "<msgD@domain.example>",
+ inReplyToHeader = "<msgC@domain.example>",
+ referencesHeader = "<msgA@domain.example>"
+ )
+ )
+
+ val threads = sqliteDatabase.readThreads()
+ assertThat(threads).hasSize(4 + 1)
+
+ val threadRoots = threads.filter { it.root == it.id }
+ assertThat(threadRoots).hasSize(1)
+
+ val threadA = threadRoots.first()
+ for (thread in threads) {
+ assertThat(thread.root).isEqualTo(threadA.id)
+ }
+
+ val messages = sqliteDatabase.readMessages()
+ val messageA = messages.first { it.id == threadA.messageId }
+ assertThat(messageA.empty).isEqualTo(1)
+ assertThat(messageA.messageId).isEqualTo("<msgA@domain.example>")
+
+ val threadB = threads.first { it.id == threadIdB }
+ assertThat(threadB.parent).isEqualTo(threadA.id)
+
+ val threadC = threads.first { it.id == threadIdC }
+ assertThat(threadC.parent).isEqualTo(threadB.id)
+
+ val threadD = threads.first { it.id == threadIdD }
+ assertThat(threadD.parent).isEqualTo(threadC.id)
+
+ val threadE = threads.first { it.id == threadIdE }
+ assertThat(threadE.parent).isEqualTo(threadD.id)
+
+ assertThat(threadInfo).isEqualTo(
+ ThreadInfo(
+ threadId = threadIdD,
+ messageId = messageIdD,
+ rootId = threadA.id!!,
+ parentId = threadIdC
+ )
+ )
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt
new file mode 100644
index 0000000..86283f8
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt
@@ -0,0 +1,167 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import com.fsck.k9.mail.FolderClass
+import com.fsck.k9.mailstore.Folder
+import com.fsck.k9.mailstore.FolderDetails
+import com.fsck.k9.mailstore.FolderType
+import com.fsck.k9.mailstore.MoreMessages
+import com.fsck.k9.storage.RobolectricTest
+import org.junit.Test
+import com.fsck.k9.mail.FolderType as RemoteFolderType
+
+class UpdateFolderOperationsTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+ private val updateFolderOperations = UpdateFolderOperations(lockableDatabase)
+
+ @Test
+ fun `change folder`() {
+ sqliteDatabase.createFolder(serverId = "folder1", name = "Old", type = "REGULAR")
+
+ updateFolderOperations.changeFolder("folder1", "New", RemoteFolderType.TRASH)
+
+ val folder = sqliteDatabase.readFolders().first()
+ assertThat(folder.serverId).isEqualTo("folder1")
+ assertThat(folder.name).isEqualTo("New")
+ assertThat(folder.type).isEqualTo("trash")
+ }
+
+ @Test
+ fun `update folder settings`() {
+ val folderId = sqliteDatabase.createFolder(
+ inTopGroup = false,
+ integrate = false,
+ displayClass = "NO_CLASS",
+ syncClass = "NO_CLASS",
+ notifyClass = "NO_CLASS",
+ pushClass = "NO_CLASS"
+ )
+
+ updateFolderOperations.updateFolderSettings(
+ FolderDetails(
+ folder = Folder(
+ id = folderId,
+ name = "irrelevant",
+ type = FolderType.REGULAR,
+ isLocalOnly = false
+ ),
+ isInTopGroup = true,
+ isIntegrate = true,
+ displayClass = FolderClass.FIRST_CLASS,
+ syncClass = FolderClass.FIRST_CLASS,
+ notifyClass = FolderClass.FIRST_CLASS,
+ pushClass = FolderClass.FIRST_CLASS
+ )
+ )
+
+ val folder = sqliteDatabase.readFolders().first()
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.inTopGroup).isEqualTo(1)
+ assertThat(folder.integrate).isEqualTo(1)
+ assertThat(folder.displayClass).isEqualTo("FIRST_CLASS")
+ assertThat(folder.syncClass).isEqualTo("FIRST_CLASS")
+ assertThat(folder.notifyClass).isEqualTo("FIRST_CLASS")
+ assertThat(folder.pushClass).isEqualTo("FIRST_CLASS")
+ }
+
+ @Test
+ fun `update integrate setting`() {
+ val folderId = sqliteDatabase.createFolder(integrate = false)
+
+ updateFolderOperations.setIncludeInUnifiedInbox(folderId = folderId, includeInUnifiedInbox = true)
+
+ val folder = sqliteDatabase.readFolders().first()
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.integrate).isEqualTo(1)
+ }
+
+ @Test
+ fun `update display class`() {
+ val folderId = sqliteDatabase.createFolder(displayClass = "FIRST_CLASS")
+
+ updateFolderOperations.setDisplayClass(folderId = folderId, folderClass = FolderClass.SECOND_CLASS)
+
+ val folder = sqliteDatabase.readFolders().first()
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.displayClass).isEqualTo("SECOND_CLASS")
+ }
+
+ @Test
+ fun `update sync class`() {
+ val folderId = sqliteDatabase.createFolder(syncClass = "FIRST_CLASS")
+
+ updateFolderOperations.setSyncClass(folderId = folderId, folderClass = FolderClass.NO_CLASS)
+
+ val folder = sqliteDatabase.readFolders().first()
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.syncClass).isEqualTo("NO_CLASS")
+ }
+
+ @Test
+ fun `update push class`() {
+ val folderId = sqliteDatabase.createFolder(pushClass = "FIRST_CLASS")
+
+ updateFolderOperations.setPushClass(folderId = folderId, folderClass = FolderClass.NO_CLASS)
+
+ val folder = sqliteDatabase.readFolders().first()
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.pushClass).isEqualTo("NO_CLASS")
+ }
+
+ @Test
+ fun `update notification class`() {
+ val folderId = sqliteDatabase.createFolder(syncClass = "FIRST_CLASS")
+
+ updateFolderOperations.setNotificationClass(folderId = folderId, folderClass = FolderClass.INHERITED)
+
+ val folder = sqliteDatabase.readFolders().first()
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.notifyClass).isEqualTo("INHERITED")
+ }
+
+ @Test
+ fun `update more messages state`() {
+ val folderId = sqliteDatabase.createFolder(moreMessages = "unknown")
+
+ updateFolderOperations.setMoreMessages(folderId = folderId, moreMessages = MoreMessages.TRUE)
+
+ val folder = sqliteDatabase.readFolders().first()
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.moreMessages).isEqualTo("true")
+ }
+
+ @Test
+ fun `update late updated state`() {
+ val folderId = sqliteDatabase.createFolder(lastUpdated = 23)
+
+ updateFolderOperations.setLastChecked(folderId = folderId, timestamp = 42)
+
+ val folder = sqliteDatabase.readFolders().first()
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.lastUpdated).isEqualTo(42)
+ }
+
+ @Test
+ fun `update folder status`() {
+ val folderId = sqliteDatabase.createFolder(status = null)
+
+ updateFolderOperations.setStatus(folderId = folderId, status = "Sync error")
+
+ val folder = sqliteDatabase.readFolders().first()
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.status).isEqualTo("Sync error")
+ }
+
+ @Test
+ fun `update visible limit`() {
+ val folderId = sqliteDatabase.createFolder(visibleLimit = 10)
+
+ updateFolderOperations.setVisibleLimit(folderId = folderId, visibleLimit = 25)
+
+ val folder = sqliteDatabase.readFolders().first()
+ assertThat(folder.id).isEqualTo(folderId)
+ assertThat(folder.visibleLimit).isEqualTo(25)
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateMessageOperationsTest.kt
new file mode 100644
index 0000000..8bc95c4
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateMessageOperationsTest.kt
@@ -0,0 +1,54 @@
+package com.fsck.k9.storage.messages
+
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.isEqualTo
+import com.fsck.k9.storage.RobolectricTest
+import org.junit.Test
+
+class UpdateMessageOperationsTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+ private val updateMessageOperations = UpdateMessageOperations(lockableDatabase)
+
+ @Test
+ fun `mark message as new`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = false)
+
+ updateMessageOperations.setNewMessageState(folderId = 1, messageServerId = "uid1", newMessage = true)
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(1)
+
+ val message = messages.first()
+ assertThat(message.newMessage).isEqualTo(1)
+ }
+
+ @Test
+ fun `mark message as not new`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = true)
+
+ updateMessageOperations.setNewMessageState(folderId = 1, messageServerId = "uid1", newMessage = false)
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(1)
+
+ val message = messages.first()
+ assertThat(message.newMessage).isEqualTo(0)
+ }
+
+ @Test
+ fun `clear new message state`() {
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = true)
+ sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = false)
+
+ updateMessageOperations.clearNewMessageState()
+
+ val messages = sqliteDatabase.readMessages()
+ assertThat(messages).hasSize(2)
+
+ for (message in messages) {
+ assertThat(message.newMessage).isEqualTo(0)
+ }
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/notifications/K9NotificationStoreTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/notifications/K9NotificationStoreTest.kt
new file mode 100644
index 0000000..108cd78
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/notifications/K9NotificationStoreTest.kt
@@ -0,0 +1,107 @@
+package com.fsck.k9.storage.notifications
+
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.isEmpty
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNull
+import com.fsck.k9.controller.MessageReference
+import com.fsck.k9.notification.NotificationStoreOperation
+import com.fsck.k9.storage.RobolectricTest
+import com.fsck.k9.storage.messages.createDatabase
+import com.fsck.k9.storage.messages.createLockableDatabaseMock
+import com.fsck.k9.storage.messages.createMessage
+import org.junit.Test
+
+private const val FOLDER_ID = 1L
+
+class K9NotificationStoreTest : RobolectricTest() {
+ private val sqliteDatabase = createDatabase()
+ private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+ private val store = K9NotificationStore(lockableDatabase)
+ private val messageIdOne = sqliteDatabase.createMessage(folderId = FOLDER_ID, uid = "uid-1")
+ private val messageIdTwo = sqliteDatabase.createMessage(folderId = FOLDER_ID, uid = "uid-2")
+
+ @Test
+ fun `add notification`() {
+ val operations = listOf(
+ NotificationStoreOperation.Add(
+ messageReference = createMessageReference("uid-1"),
+ notificationId = 42,
+ timestamp = 23L
+ )
+ )
+
+ store.persistNotificationChanges(operations)
+
+ val notifications = sqliteDatabase.readNotifications()
+ assertThat(notifications).hasSize(1)
+ val notification = notifications.first()
+ assertThat(notification.messageId).isEqualTo(messageIdOne)
+ assertThat(notification.notificationId).isEqualTo(42)
+ assertThat(notification.timestamp).isEqualTo(23L)
+ }
+
+ @Test
+ fun `replace notification when adding`() {
+ sqliteDatabase.createNotification(messageId = messageIdOne, notificationId = 10, timestamp = 20L)
+ val operations = listOf(
+ NotificationStoreOperation.ChangeToInactive(messageReference = createMessageReference("uid-1")),
+ NotificationStoreOperation.Add(
+ messageReference = createMessageReference("uid-2"),
+ notificationId = 10,
+ timestamp = 30L
+ )
+ )
+
+ store.persistNotificationChanges(operations)
+
+ val notifications = sqliteDatabase.readNotifications()
+ assertThat(notifications).hasSize(2)
+ val originalNotification = notifications.first { it.messageId == messageIdOne }
+ assertThat(originalNotification.notificationId).isNull()
+ assertThat(originalNotification.timestamp).isEqualTo(20L)
+ val newNotification = notifications.first { it.messageId == messageIdTwo }
+ assertThat(newNotification.notificationId).isEqualTo(10)
+ assertThat(newNotification.timestamp).isEqualTo(30L)
+ }
+
+ @Test
+ fun `remove notification`() {
+ sqliteDatabase.createNotification(messageId = messageIdOne, notificationId = 10, timestamp = 20L)
+ val operations = listOf(
+ NotificationStoreOperation.Remove(messageReference = createMessageReference("uid-1"))
+ )
+
+ store.persistNotificationChanges(operations)
+
+ val notifications = sqliteDatabase.readNotifications()
+ assertThat(notifications).isEmpty()
+ }
+
+ @Test
+ fun `replace notification when removing`() {
+ sqliteDatabase.createNotification(messageId = messageIdOne, notificationId = null, timestamp = 20L)
+ sqliteDatabase.createNotification(messageId = messageIdTwo, notificationId = 23, timestamp = 21L)
+ val operations = listOf(
+ NotificationStoreOperation.Remove(messageReference = createMessageReference("uid-2")),
+ NotificationStoreOperation.ChangeToActive(
+ messageReference = createMessageReference("uid-1"),
+ notificationId = 23
+ )
+ )
+
+ store.persistNotificationChanges(operations)
+
+ val notifications = sqliteDatabase.readNotifications()
+ assertThat(notifications).hasSize(1)
+ val notification = notifications.first()
+ assertThat(notification.messageId).isEqualTo(messageIdOne)
+ assertThat(notification.notificationId).isEqualTo(23)
+ assertThat(notification.timestamp).isEqualTo(20L)
+ }
+
+ private fun createMessageReference(uid: String): MessageReference {
+ return MessageReference(accountUuid = "00000000-0000-4000-0000-000000000000", FOLDER_ID, uid)
+ }
+}
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/notifications/NotificationsTableHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/notifications/NotificationsTableHelpers.kt
new file mode 100644
index 0000000..a2780e6
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/notifications/NotificationsTableHelpers.kt
@@ -0,0 +1,40 @@
+package com.fsck.k9.storage.notifications
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import app.k9mail.core.android.common.database.getIntOrNull
+import app.k9mail.core.android.common.database.getLongOrNull
+import app.k9mail.core.android.common.database.map
+
+fun SQLiteDatabase.createNotification(
+ messageId: Long,
+ notificationId: Int? = null,
+ timestamp: Long = 0L
+): Long {
+ val values = ContentValues().apply {
+ put("message_id", messageId)
+ put("notification_id", notificationId)
+ put("timestamp", timestamp)
+ }
+
+ return insert("notifications", null, values)
+}
+
+fun SQLiteDatabase.readNotifications(): List<NotificationEntry> {
+ val cursor = rawQuery("SELECT * FROM notifications", null)
+ return cursor.use {
+ cursor.map {
+ NotificationEntry(
+ messageId = cursor.getLongOrNull("message_id"),
+ notificationId = cursor.getIntOrNull("notification_id"),
+ timestamp = cursor.getLongOrNull("timestamp")
+ )
+ }
+ }
+}
+
+data class NotificationEntry(
+ val messageId: Long?,
+ val notificationId: Int?,
+ val timestamp: Long?
+)