diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/9.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/9.json new file mode 100644 index 0000000..2fd66ef --- /dev/null +++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/9.json @@ -0,0 +1,509 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "e753f578e604e04fa7a697c18e80cb0b", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authType", + "columnName": "authType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imap.host", + "columnName": "imap_host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imap.port", + "columnName": "imap_port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imap.security", + "columnName": "imap_security", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smtp.host", + "columnName": "smtp_host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smtp.port", + "columnName": "smtp_port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "smtp.security", + "columnName": "smtp_security", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "account_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signatureEnabled", + "columnName": "signatureEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderEmail", + "columnName": "senderEmail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "snippet", + "columnName": "snippet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isHtml", + "columnName": "isHtml", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestampMillis", + "columnName": "timestampMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStarred", + "columnName": "isStarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "folder", + "columnName": "folder", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'INBOX'" + }, + { + "fieldPath": "inInbox", + "columnName": "inInbox", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bodyFetched", + "columnName": "bodyFetched", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_messages_timestampMillis", + "unique": false, + "columnNames": [ + "timestampMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)" + } + ] + }, + { + "tableName": "credentials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedSecret", + "columnName": "encryptedSecret", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + } + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "partIndex", + "columnName": "partIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageId", + "partIndex" + ] + }, + "indices": [ + { + "name": "index_attachments_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "messages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "messageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toAddresses", + "columnName": "toAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccAddresses", + "columnName": "ccAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "drafts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT" + }, + { + "fieldPath": "toAddresses", + "columnName": "toAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccAddresses", + "columnName": "ccAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullName", + "columnName": "fullName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectable", + "columnName": "selectable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "fullName" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e753f578e604e04fa7a697c18e80cb0b')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt index 2347c15..460b2e2 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt @@ -9,13 +9,17 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.AccountSettingsEntity import org.libremail.data.local.entity.AttachmentEntity import org.libremail.data.local.entity.FolderEntity import org.libremail.data.local.entity.MessageEntity +import org.libremail.data.local.entity.ServerConfigEmbedded /** * Schema-behavior tests on the real (v7) Room database. (Migrations from versions before @@ -65,6 +69,30 @@ class LibreMailDatabaseTest { ) } + @Test + fun accountSettingsRoundTripAndCascadeWithTheirAccount() = runBlocking { + val accountDao = db.accountDao() + val settingsDao = db.accountSettingsDao() + accountDao.upsert( + AccountEntity( + id = "acct", + email = "a@example.org", + displayName = "A", + authType = "PASSWORD_IMAP", + imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"), + smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"), + ), + ) + settingsDao.upsert( + AccountSettingsEntity("acct", signature = "Hi", signatureEnabled = false, notificationsEnabled = false), + ) + assertEquals("Hi", settingsDao.get("acct")?.signature) + + accountDao.deleteById("acct") + + assertNull("account_settings must cascade-delete with its account", settingsDao.get("acct")) + } + @Test fun searchRowsAreNotInboxAndAreCleared() = runBlocking { val messageDao = db.messageDao() diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt index 063150f..e253961 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt @@ -11,8 +11,10 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.lifecycle.SavedStateHandle +import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -20,6 +22,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.libremail.R import org.libremail.contacts.ContactsRepository +import org.libremail.data.local.LibreMailDatabase +import org.libremail.data.settings.AccountSettingsRepository import org.libremail.domain.model.Account import org.libremail.domain.model.AuthType import org.libremail.domain.model.MailSecurity @@ -47,8 +51,15 @@ class ComposeScreenTest { smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS), ) + private var db: LibreMailDatabase? = null + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + @After + fun closeDb() { + db?.close() + } + @Before fun grantContactsPermission() { // ComposeScreen requests READ_CONTACTS on first composition; pre-grant it (before the test @@ -66,11 +77,13 @@ class ComposeScreenTest { onBack: () -> Unit = {}, ) { val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + val database = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build().also { db = it } val viewModel = ComposeViewModel( savedStateHandle = SavedStateHandle(), mailRepository = mailRepository, accountRepository = FakeAccountRepository(accounts = listOf(account)), contactsRepository = ContactsRepository(context), + accountSettingsRepository = AccountSettingsRepository(database.accountSettingsDao()), ) composeTestRule.setContent { LibreMailTheme(darkTheme = false, dynamicColor = false) { diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt new file mode 100644 index 0000000..d4f26b7 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.SavedStateHandle +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.data.local.LibreMailDatabase +import org.libremail.data.local.toEntity +import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig +import org.libremail.ui.FakeAccountRepository +import org.libremail.ui.navigation.Routes +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end test for the per-account settings screen: editing the signature and toggling the + * per-account notification switch must round-trip through the real Room-backed repository. + */ +@RunWith(AndroidJUnit4::class) +class AccountSettingsScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val account = Account( + id = "imap:me@example.com", + email = "me@example.com", + displayName = "Me", + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.com", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS), + ) + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private fun setContent(): AccountSettingsRepository { + val context = ApplicationProvider.getApplicationContext() + // Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by + // stateIn/WhileSubscribed) keeps querying after the test body, so closing the in-memory DB out + // from under it races and crashes ("connection pool has been closed"). The DB is reclaimed with + // the test process. + val db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build() + val repository = AccountSettingsRepository(db.accountSettingsDao()) + runBlocking { + db.accountDao().upsert(account.toEntity()) // FK parent for the account_settings row + repository.ensureDefaults(account.id) + } + val viewModel = AccountSettingsViewModel( + savedStateHandle = SavedStateHandle(mapOf(Routes.ACCOUNT_SETTINGS_ARG_ID to account.id)), + accountRepository = FakeAccountRepository(accounts = listOf(account)), + accountSettingsRepository = repository, + ) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + AccountSettingsScreen(onBack = {}, viewModel = viewModel) + } + } + return repository + } + + @Test + fun editingSignature_persistsThroughTheRepository() { + val repository = setContent() + + composeTestRule.onNodeWithText(string(R.string.settings_signature_hint)).performTextInput("Cheers") + + composeTestRule.waitUntil(5_000) { + runBlocking { repository.get(account.id).signature } == "Cheers" + } + } + + @Test + fun togglingNotifications_persistsThroughTheRepository() { + val repository = setContent() // ensureDefaults starts notifications enabled + + composeTestRule.onNodeWithText(string(R.string.settings_account_new_mail)).performClick() + + composeTestRule.waitUntil(5_000) { + runBlocking { !repository.get(account.id).notificationsEnabled } + } + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt index 2ef0e1e..c3b6061 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt @@ -39,7 +39,7 @@ class SettingsScreenTest { composeTestRule.setContent { LibreMailTheme(darkTheme = false, dynamicColor = false) { - SettingsScreen(onAddAccount = {}, onSelectTab = {}, viewModel = viewModel) + SettingsScreen(onAddAccount = {}, onOpenAccount = {}, onSelectTab = {}, viewModel = viewModel) } } diff --git a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt index d538969..0b1b2e5 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -4,6 +4,7 @@ package org.libremail.data.local import androidx.room.Database import androidx.room.RoomDatabase import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.AccountSettingsDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.DraftDao @@ -11,6 +12,7 @@ import org.libremail.data.local.dao.FolderDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.dao.OutboxDao import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.AccountSettingsEntity import org.libremail.data.local.entity.AttachmentEntity import org.libremail.data.local.entity.CredentialEntity import org.libremail.data.local.entity.DraftEntity @@ -21,6 +23,7 @@ import org.libremail.data.local.entity.OutboxEntity @Database( entities = [ AccountEntity::class, + AccountSettingsEntity::class, MessageEntity::class, CredentialEntity::class, AttachmentEntity::class, @@ -28,12 +31,13 @@ import org.libremail.data.local.entity.OutboxEntity DraftEntity::class, FolderEntity::class, ], - version = 8, + version = 9, exportSchema = true, ) abstract class LibreMailDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao abstract fun accountDao(): AccountDao + abstract fun accountSettingsDao(): AccountSettingsDao abstract fun credentialDao(): CredentialDao abstract fun attachmentDao(): AttachmentDao abstract fun outboxDao(): OutboxDao diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index 52a51bb..620cf0b 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -2,6 +2,7 @@ package org.libremail.data.local import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.AccountSettingsEntity import org.libremail.data.local.entity.AttachmentEntity import org.libremail.data.local.entity.DraftEntity import org.libremail.data.local.entity.FolderEntity @@ -11,6 +12,7 @@ import org.libremail.data.local.entity.ServerConfigEmbedded import org.json.JSONArray import org.json.JSONObject import org.libremail.domain.model.Account +import org.libremail.domain.model.AccountSettings import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft import org.libremail.domain.model.OutboxMessage @@ -45,6 +47,20 @@ internal fun Account.toEntity(): AccountEntity = AccountEntity( smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name), ) +internal fun AccountSettingsEntity.toDomain(): AccountSettings = AccountSettings( + accountId = accountId, + signature = signature, + signatureEnabled = signatureEnabled, + notificationsEnabled = notificationsEnabled, +) + +internal fun AccountSettings.toEntity(): AccountSettingsEntity = AccountSettingsEntity( + accountId = accountId, + signature = signature, + signatureEnabled = signatureEnabled, + notificationsEnabled = notificationsEnabled, +) + internal fun Account.toImapParams( secret: String, useXoauth2: Boolean, diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index 1435a42..0338ce3 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -145,3 +145,29 @@ val MIGRATION_7_8 = object : Migration(7, 8) { ) } } + +/** + * v8 -> v9: per-account settings (preserves existing data). Adds the `account_settings` table with a + * cascading foreign key to `accounts`, and backfills a default row for every existing account. + * + * Columns are declared without SQL DEFAULTs and the backfill lists every column explicitly (the + * MIGRATION_4_5/5_6 pattern), so the fresh-install schema matches the migrated one — avoiding the + * `@ColumnInfo(defaultValue)` mismatch that MIGRATION_6_7 had to repair. + */ +val MIGRATION_8_9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `account_settings` (" + + "`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, " + + "`signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, " + + "PRIMARY KEY(`accountId`), " + + "FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE)", + ) + db.execSQL( + "INSERT INTO `account_settings` " + + "(`accountId`, `signature`, `signatureEnabled`, `notificationsEnabled`) " + + "SELECT `id`, '', 1, 1 FROM `accounts`", + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AccountSettingsDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AccountSettingsDao.kt new file mode 100644 index 0000000..4cd8260 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AccountSettingsDao.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import org.libremail.data.local.entity.AccountSettingsEntity + +@Dao +interface AccountSettingsDao { + @Query("SELECT * FROM account_settings WHERE accountId = :accountId LIMIT 1") + fun observe(accountId: String): Flow + + @Query("SELECT * FROM account_settings WHERE accountId = :accountId LIMIT 1") + suspend fun get(accountId: String): AccountSettingsEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(settings: AccountSettingsEntity) +} diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/AccountSettingsEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/AccountSettingsEntity.kt new file mode 100644 index 0000000..938da36 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/AccountSettingsEntity.kt @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +/** + * Per-account preferences, one row per account. The [accountId] foreign key cascades on delete, so + * removing an account also removes its settings. It is the primary key, so it is already indexed + * (no extra `@Index` needed for the foreign key). + */ +@Entity( + tableName = "account_settings", + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = ["id"], + childColumns = ["accountId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class AccountSettingsEntity( + @PrimaryKey val accountId: String, + val signature: String = "", + val signatureEnabled: Boolean = true, + val notificationsEnabled: Boolean = true, +) diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt index b03b5e0..9414fb9 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt @@ -12,11 +12,13 @@ import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity import org.libremail.data.local.toImapParams import org.libremail.data.security.CredentialStore +import org.libremail.data.settings.AccountSettingsRepository import org.libremail.data.sync.SyncScheduler import org.libremail.domain.model.Account import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.repository.AccountRepository import org.libremail.mail.ImapClient +import org.libremail.notifications.MailNotifier @Singleton class AccountRepositoryImpl @Inject constructor( @@ -26,6 +28,8 @@ class AccountRepositoryImpl @Inject constructor( private val credentialStore: CredentialStore, private val imapClient: ImapClient, private val syncScheduler: SyncScheduler, + private val accountSettingsRepository: AccountSettingsRepository, + private val mailNotifier: MailNotifier, ) : AccountRepository { override fun observeAccounts(): Flow> = @@ -37,7 +41,9 @@ class AccountRepositoryImpl @Inject constructor( override suspend fun addImapAccount(account: Account, password: String): Result> = runCatching { val folders = imapClient.listFolders(account.toImapParams(secret = password, useXoauth2 = false)) accountDao.upsert(account.toEntity()) + accountSettingsRepository.ensureDefaults(account.id) credentialStore.saveSecret(account.id, password) + mailNotifier.ensureAccountChannel(account) syncScheduler.syncNow() folders.map { it.fullName } } @@ -50,7 +56,9 @@ class AccountRepositoryImpl @Inject constructor( val account = Account.outlook(email) val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true)) accountDao.upsert(account.toEntity()) + accountSettingsRepository.ensureDefaults(account.id) credentialStore.saveSecret(account.id, authStateJson) + mailNotifier.ensureAccountChannel(account) syncScheduler.syncNow() folders.map { it.fullName } } @@ -58,7 +66,9 @@ class AccountRepositoryImpl @Inject constructor( override suspend fun deleteAccount(id: String) { accountDao.deleteById(id) credentialStore.delete(id) + mailNotifier.deleteAccountChannel(id) // Remove the account's cached mail (attachment rows cascade via the foreign key) and folders. + // The account_settings row is removed automatically by its cascading foreign key. messageDao.deleteByAccount(id) folderDao.deleteForAccount(id) } diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index b299d89..43ccd42 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -23,6 +23,7 @@ import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.OutboxEntity import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity +import org.libremail.data.settings.AccountSettingsRepository import org.libremail.data.sync.MailConnectionFactory import org.libremail.data.sync.SendScheduler import org.libremail.domain.model.Attachment @@ -50,6 +51,7 @@ class MailRepositoryImpl @Inject constructor( private val imapClient: ImapClient, private val connectionFactory: MailConnectionFactory, private val sendScheduler: SendScheduler, + private val accountSettingsRepository: AccountSettingsRepository, ) : MailRepository { override fun observeMessages(): Flow> = @@ -178,6 +180,9 @@ class MailRepositoryImpl @Inject constructor( val params = connectionFactory.imapParamsFor(account) val context = imapClient.fetchForReply(params, entity.folder, uidOf(messageId)) val content = ReplyBuilder.build(context, mode, account.email) + // Bake the sending account's signature into the reply/forward body so it round-trips as part + // of the draft (compose won't re-append for drafts). + val signature = accountSettingsRepository.get(entity.accountId).signatureBlock() val draftId = UUID.randomUUID().toString() saveDraft( Draft( @@ -186,7 +191,7 @@ class MailRepositoryImpl @Inject constructor( to = content.to, cc = content.cc, subject = content.subject, - body = content.body, + body = content.body + signature, updatedAt = System.currentTimeMillis(), attachments = emptyList(), ), diff --git a/app/src/main/kotlin/org/libremail/data/settings/AccountSettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/AccountSettingsRepository.kt new file mode 100644 index 0000000..3f86c8e --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/settings/AccountSettingsRepository.kt @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.settings + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.libremail.data.local.dao.AccountSettingsDao +import org.libremail.data.local.toDomain +import org.libremail.data.local.toEntity +import org.libremail.domain.model.AccountSettings + +/** + * Reads and writes per-account preferences. A missing row is treated as the defaults for that + * account, so callers never have to special-case "not configured yet". + */ +@Singleton +class AccountSettingsRepository @Inject constructor( + private val dao: AccountSettingsDao, +) { + fun observe(accountId: String): Flow = + dao.observe(accountId).map { it?.toDomain() ?: AccountSettings(accountId) } + + suspend fun get(accountId: String): AccountSettings = + dao.get(accountId)?.toDomain() ?: AccountSettings(accountId) + + /** Inserts a default settings row for a freshly-added account (no-op if one already exists). */ + suspend fun ensureDefaults(accountId: String) { + if (dao.get(accountId) == null) dao.upsert(AccountSettings(accountId).toEntity()) + } + + suspend fun setSignature(accountId: String, signature: String) = + update(accountId) { it.copy(signature = signature) } + + suspend fun setSignatureEnabled(accountId: String, enabled: Boolean) = + update(accountId) { it.copy(signatureEnabled = enabled) } + + suspend fun setNotificationsEnabled(accountId: String, enabled: Boolean) = + update(accountId) { it.copy(notificationsEnabled = enabled) } + + private suspend inline fun update(accountId: String, transform: (AccountSettings) -> AccountSettings) { + dao.upsert(transform(get(accountId)).toEntity()) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt index 7712015..53c9cd7 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -17,6 +17,7 @@ import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity +import org.libremail.data.settings.AccountSettingsRepository import org.libremail.data.settings.FetchPolicy import org.libremail.data.settings.SettingsRepository import org.libremail.domain.model.Account @@ -33,6 +34,7 @@ class MailSyncer @Inject constructor( private val imapClient: ImapClient, private val connectionFactory: MailConnectionFactory, private val settingsRepository: SettingsRepository, + private val accountSettingsRepository: AccountSettingsRepository, private val notifier: MailNotifier, private val mailRepository: MailRepository, ) : Syncer { @@ -119,8 +121,11 @@ class MailSyncer @Inject constructor( messageDao.deleteSyncedNotIn(account.id, folder, ids) } - if (notify && newMessages.isNotEmpty() && settingsRepository.isNewMailNotificationsEnabled()) { - notifier.notifyNewMail(newMessages.sortedByDescending { it.timestampMillis }) + if (notify && newMessages.isNotEmpty() && + settingsRepository.isNewMailNotificationsEnabled() && + accountSettingsRepository.get(account.id).notificationsEnabled + ) { + notifier.notifyNewMail(account, newMessages.sortedByDescending { it.timestampMillis }) } } fetched.size diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index 70a68b7..e0a79d8 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -21,7 +21,9 @@ import org.libremail.data.local.MIGRATION_4_5 import org.libremail.data.local.MIGRATION_5_6 import org.libremail.data.local.MIGRATION_6_7 import org.libremail.data.local.MIGRATION_7_8 +import org.libremail.data.local.MIGRATION_8_9 import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.AccountSettingsDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.DraftDao @@ -51,6 +53,7 @@ object DatabaseModule { MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, + MIGRATION_8_9, ) // No destructive fallback: the migration chain is complete, and silently dropping the // accounts/credentials/mail tables would lose stored secrets. A missing migration should @@ -79,6 +82,10 @@ object DatabaseModule { @Provides fun provideAccountDao(database: LibreMailDatabase): AccountDao = database.accountDao() + @Provides + fun provideAccountSettingsDao(database: LibreMailDatabase): AccountSettingsDao = + database.accountSettingsDao() + @Provides fun provideCredentialDao(database: LibreMailDatabase): CredentialDao = database.credentialDao() diff --git a/app/src/main/kotlin/org/libremail/domain/model/AccountSettings.kt b/app/src/main/kotlin/org/libremail/domain/model/AccountSettings.kt new file mode 100644 index 0000000..209026c --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/AccountSettings.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** Per-account user preferences (signature, notification gating). Defaults apply when unset. */ +data class AccountSettings( + val accountId: String, + val signature: String = "", + val signatureEnabled: Boolean = true, + val notificationsEnabled: Boolean = true, +) { + /** + * The block to append to a compose body, or "" when disabled or blank. Uses the RFC 3676 + * signature delimiter ("-- " on its own line) so downstream clients recognize it as a signature. + */ + fun signatureBlock(): String = + if (signatureEnabled && signature.isNotBlank()) "\n\n-- \n${signature.trimEnd()}" else "" +} diff --git a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt index 4eae29f..1d2c132 100644 --- a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt +++ b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt @@ -5,6 +5,7 @@ import android.Manifest import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel +import android.app.NotificationChannelGroup import android.app.NotificationManager import android.app.PendingIntent import android.content.Context @@ -19,24 +20,32 @@ import javax.inject.Singleton import org.libremail.MainActivity import org.libremail.R import org.libremail.data.local.entity.MessageEntity +import org.libremail.domain.model.Account -/** Posts on-device new-mail notifications (no push service involved). */ +/** + * Posts on-device new-mail notifications (no push service involved). Each account gets its own + * notification channel inside its own channel group, so Android exposes per-account sound/vibration/ + * importance in system settings and the shade bundles each account's mail separately. + */ @Singleton class MailNotifier @Inject constructor( @ApplicationContext private val context: Context, ) { // Permission is checked via hasPermission() below; lint can't trace the indirect guard. @SuppressLint("MissingPermission") - fun notifyNewMail(messages: List) { + fun notifyNewMail(account: Account, messages: List) { if (messages.isEmpty() || !hasPermission()) return - ensureChannel() + ensureAccountChannel(account) val manager = NotificationManagerCompat.from(context) val contentIntent = contentIntent() + val channelId = channelId(account.id) + val groupKey = groupKey(account.id) + val summaryId = summaryId(account.id) // One notification per message, keyed by a stable id, so a later batch never overwrites an // earlier, still-unacknowledged one. setOnlyAlertOnce avoids re-buzzing for the same message. messages.forEach { message -> - val notification = NotificationCompat.Builder(context, CHANNEL_ID) + val notification = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_launcher_monochrome) .setContentTitle(message.sender) .setContentText(message.subject) @@ -44,29 +53,58 @@ class MailNotifier @Inject constructor( .setCategory(NotificationCompat.CATEGORY_EMAIL) .setAutoCancel(true) .setOnlyAlertOnce(true) - .setGroup(GROUP_KEY) + .setGroup(groupKey) .setContentIntent(contentIntent) .build() - manager.notify(notificationId(message.id), notification) + manager.notify(notificationId(message.id, summaryId), notification) } - // Group summary (the system shows it only once two or more children are present). - val summary = NotificationCompat.Builder(context, CHANNEL_ID) + // Per-account group summary (the system shows it only once two or more children are present). + val summary = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_launcher_monochrome) - .setContentTitle(context.getString(R.string.notif_channel_new_mail)) + .setContentTitle(account.email) .setStyle( NotificationCompat.InboxStyle().also { style -> + style.setSummaryText(account.email) messages.take(SUMMARY_LINES).forEach { style.addLine("${it.sender}: ${it.subject}") } }, ) .setCategory(NotificationCompat.CATEGORY_EMAIL) .setAutoCancel(true) .setOnlyAlertOnce(true) - .setGroup(GROUP_KEY) + .setGroup(groupKey) .setGroupSummary(true) .setContentIntent(contentIntent) .build() - manager.notify(SUMMARY_ID, summary) + manager.notify(summaryId, summary) + } + + /** + * Creates the account's channel (inside a per-account group named by its email), so Android owns + * this account's sound/vibration/importance and the app can deep-link into them. Idempotent — + * Android ignores changes to an existing channel. Also retires the pre-per-account global channel. + */ + fun ensureAccountChannel(account: Account) { + val manager = context.getSystemService(NotificationManager::class.java) ?: return + manager.deleteNotificationChannel(LEGACY_CHANNEL_ID) + manager.createNotificationChannelGroup(NotificationChannelGroup(account.id, account.email)) + val channel = NotificationChannel( + channelId(account.id), + context.getString(R.string.notif_channel_new_mail), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + group = account.id + // Redact sender/subject on a secure lock screen (system shows a generic placeholder). + lockscreenVisibility = Notification.VISIBILITY_PRIVATE + } + manager.createNotificationChannel(channel) + } + + /** Removes an account's channel and group — called when the account is deleted. */ + fun deleteAccountChannel(accountId: String) { + val manager = context.getSystemService(NotificationManager::class.java) ?: return + manager.deleteNotificationChannel(channelId(accountId)) + manager.deleteNotificationChannelGroup(accountId) } private fun contentIntent(): PendingIntent { @@ -81,33 +119,25 @@ class MailNotifier @Inject constructor( ) } - /** Stable per-message id distinct from the summary id, so each message gets its own notification. */ - private fun notificationId(messageId: String): Int { + /** Stable per-message id distinct from the account's summary id, so each message gets its own. */ + private fun notificationId(messageId: String, summaryId: Int): Int { val hash = messageId.hashCode() - return if (hash == SUMMARY_ID) hash + 1 else hash + return if (hash == summaryId) hash + 1 else hash } private fun hasPermission(): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED - private fun ensureChannel() { - val channel = NotificationChannel( - CHANNEL_ID, - context.getString(R.string.notif_channel_new_mail), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - // Redact sender/subject on a secure lock screen (the system shows a generic placeholder - // instead). Applies on fresh installs; Android ignores channel changes after creation. - lockscreenVisibility = Notification.VISIBILITY_PRIVATE - } - NotificationManagerCompat.from(context).createNotificationChannel(channel) - } + companion object { + /** The single global channel used before notifications became per-account. */ + private const val LEGACY_CHANNEL_ID = "new_mail" + private const val SUMMARY_LINES = 5 - private companion object { - const val CHANNEL_ID = "new_mail" - const val GROUP_KEY = "org.libremail.NEW_MAIL" - const val SUMMARY_ID = 1001 - const val SUMMARY_LINES = 5 + /** This account's new-mail channel id — also used to deep-link into its system settings. */ + fun channelId(accountId: String) = "new_mail:$accountId" + + private fun groupKey(accountId: String) = "org.libremail.NEW_MAIL:$accountId" + private fun summaryId(accountId: String) = "summary:$accountId".hashCode() } } diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 2b5c3c9..fecef90 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -27,6 +27,7 @@ import org.libremail.ui.mailbox.MailboxScreen import org.libremail.ui.navigation.Routes import org.libremail.ui.outbox.OutboxScreen import org.libremail.ui.reader.ReaderScreen +import org.libremail.ui.settings.AccountSettingsScreen import org.libremail.ui.settings.SettingsScreen /** Top-level destinations shown in the bottom navigation bar. */ @@ -77,9 +78,16 @@ fun LibreMailApp() { composable(Routes.SETTINGS) { SettingsScreen( onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) }, + onOpenAccount = { accountId -> navController.navigate(Routes.accountSettings(accountId)) }, onSelectTab = navController::navigateTab, ) } + composable( + route = Routes.ACCOUNT_SETTINGS_PATTERN, + arguments = listOf(navArgument(Routes.ACCOUNT_SETTINGS_ARG_ID) { type = NavType.StringType }), + ) { + AccountSettingsScreen(onBack = navController::popBackStack) + } composable(Routes.ACCOUNT_SETUP) { AccountSetupScreen( onBack = navController::popBackStack, diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 7c640e3..c80b9aa 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.libremail.contacts.ContactSuggestion import org.libremail.contacts.ContactsRepository +import org.libremail.data.settings.AccountSettingsRepository import org.libremail.domain.model.Account import org.libremail.domain.model.Draft import org.libremail.domain.model.OutgoingAttachment @@ -47,6 +48,7 @@ class ComposeViewModel @Inject constructor( private val mailRepository: MailRepository, private val accountRepository: AccountRepository, private val contactsRepository: ContactsRepository, + private val accountSettingsRepository: AccountSettingsRepository, ) : ViewModel() { private val draftId: String? = @@ -73,6 +75,9 @@ class ComposeViewModel @Inject constructor( /** Guards against double-navigation and against saving a draft for an already-sent message. */ @Volatile private var navigated = false + /** The signature block last appended to the body, so a From-change can swap it out cleanly. */ + private var appliedSignatureBlock = "" + init { if (draftId != null) { viewModelScope.launch { @@ -89,6 +94,14 @@ class ComposeViewModel @Inject constructor( } } } + } else { + // New composition (incl. reader-reply, which prefills From): append the sending account's + // signature. Reply/forward drafts already carry theirs, so they take the draft branch above. + viewModelScope.launch { + val available = accountRepository.observeAccounts().first { it.isNotEmpty() } + val effectiveId = _state.value.fromAccountId ?: available.first().id + applySignature(effectiveId) + } } } @@ -100,7 +113,26 @@ class ComposeViewModel @Inject constructor( fun onCcChange(value: String) = _state.update { it.copy(cc = value) } fun onSubjectChange(value: String) = _state.update { it.copy(subject = value) } fun onBodyChange(value: String) = _state.update { it.copy(body = value) } - fun selectFrom(accountId: String) = _state.update { it.copy(fromAccountId = accountId) } + fun selectFrom(accountId: String) { + viewModelScope.launch { applySignature(accountId) } + } + + /** + * Sets the sending account and swaps its signature into the body: strips the previously-appended + * signature block (when the body still ends with it) and appends the newly-selected account's. + */ + private suspend fun applySignature(accountId: String) { + val block = accountSettingsRepository.get(accountId).signatureBlock() + _state.update { s -> + val base = if (appliedSignatureBlock.isNotEmpty() && s.body.endsWith(appliedSignatureBlock)) { + s.body.removeSuffix(appliedSignatureBlock) + } else { + s.body + } + s.copy(fromAccountId = accountId, body = base + block) + } + appliedSignatureBlock = block + } fun addAttachments(items: List) = _state.update { it.copy(attachments = it.attachments + items) } fun removeAttachment(uri: String) = diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index d65e6dc..5ddb2d5 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -16,6 +16,10 @@ object Routes { const val READER_PATTERN = "reader/{$READER_ARG_ID}" fun reader(messageId: String) = "reader/${Uri.encode(messageId)}" + const val ACCOUNT_SETTINGS_ARG_ID = "accountId" + const val ACCOUNT_SETTINGS_PATTERN = "account_settings/{$ACCOUNT_SETTINGS_ARG_ID}" + fun accountSettings(accountId: String) = "account_settings/${Uri.encode(accountId)}" + const val COMPOSE_ARG_TO = "to" const val COMPOSE_ARG_SUBJECT = "subject" const val COMPOSE_ARG_FROM = "from" diff --git a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt new file mode 100644 index 0000000..a228936 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.libremail.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSettingsScreen( + onBack: () -> Unit, + viewModel: AccountSettingsViewModel = hiltViewModel(), +) { + val account by viewModel.account.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val signature by viewModel.signature.collectAsStateWithLifecycle() + val context = LocalContext.current + val fallbackTitle = stringResource(R.string.settings_account_title) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(account?.email ?: fallbackTitle) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + SectionHeader(stringResource(R.string.settings_signature)) + SwitchRow( + title = stringResource(R.string.settings_signature_enable), + checked = settings.signatureEnabled, + onCheckedChange = viewModel::setSignatureEnabled, + ) + OutlinedTextField( + value = signature ?: "", + onValueChange = viewModel::onSignatureChange, + enabled = settings.signatureEnabled, + label = { Text(stringResource(R.string.settings_signature_hint)) }, + minLines = 3, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + HorizontalDivider() + + SectionHeader(stringResource(R.string.settings_account_notifications)) + SwitchRow( + title = stringResource(R.string.settings_account_new_mail), + checked = settings.notificationsEnabled, + onCheckedChange = viewModel::setNotificationsEnabled, + subtitle = stringResource(R.string.settings_account_new_mail_summary), + ) + ClickRow( + title = stringResource(R.string.settings_account_system_notif), + subtitle = stringResource(R.string.settings_account_system_notif_summary), + onClick = { openChannelSettings(context, viewModel.notificationChannelId) }, + ) + HorizontalDivider() + + ClickRow( + title = stringResource(R.string.account_remove), + titleColor = MaterialTheme.colorScheme.error, + onClick = { viewModel.removeAccount(onBack) }, + ) + } + } +} + +/** + * Opens Android's system notification settings for this account's channel (where the user controls + * sound, vibration, and importance). Falls back to the app's notification settings if the channel + * screen can't be shown. + */ +private fun openChannelSettings(context: Context, channelId: String) { + val channelIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, channelId) + } + runCatching { context.startActivity(channelIntent) }.onFailure { + runCatching { + context.startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName), + ) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt new file mode 100644 index 0000000..41317e0 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.domain.model.Account +import org.libremail.domain.model.AccountSettings +import org.libremail.domain.repository.AccountRepository +import org.libremail.notifications.MailNotifier +import org.libremail.ui.navigation.Routes + +@HiltViewModel +class AccountSettingsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val accountRepository: AccountRepository, + private val accountSettingsRepository: AccountSettingsRepository, +) : ViewModel() { + + private val accountId: String = + checkNotNull(savedStateHandle.get(Routes.ACCOUNT_SETTINGS_ARG_ID)) + + val account: StateFlow = accountRepository.observeAccounts() + .map { list -> list.firstOrNull { it.id == accountId } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + val settings: StateFlow = accountSettingsRepository.observe(accountId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AccountSettings(accountId)) + + // The signature text is edited locally (seeded once from persistence) so the field stays + // responsive — a fully DB-driven value would lag each keystroke and jump the cursor. + private val _signature = MutableStateFlow(null) + val signature: StateFlow = _signature.asStateFlow() + + /** This account's notification channel id, for deep-linking into Android's system settings. */ + val notificationChannelId: String = MailNotifier.channelId(accountId) + + init { + viewModelScope.launch { + val loaded = accountSettingsRepository.get(accountId).signature + _signature.update { it ?: loaded } // don't clobber any text typed before the load returned + } + } + + fun onSignatureChange(value: String) { + _signature.value = value + viewModelScope.launch { accountSettingsRepository.setSignature(accountId, value) } + } + + fun setSignatureEnabled(value: Boolean) { + viewModelScope.launch { accountSettingsRepository.setSignatureEnabled(accountId, value) } + } + + fun setNotificationsEnabled(value: Boolean) { + viewModelScope.launch { accountSettingsRepository.setNotificationsEnabled(accountId, value) } + } + + fun removeAccount(onRemoved: () -> Unit) { + viewModelScope.launch { + accountRepository.deleteAccount(accountId) + onRemoved() + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsComponents.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsComponents.kt new file mode 100644 index 0000000..9a4271d --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsComponents.kt @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** Shared row/header composables used by both the global and per-account settings screens. */ + +@Composable +internal fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), + ) +} + +@Composable +internal fun SwitchRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + subtitle: String? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + if (subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.width(16.dp)) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +internal fun ClickRow( + title: String, + onClick: () -> Unit, + subtitle: String? = null, + titleColor: Color = Color.Unspecified, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge, color = titleColor) + if (subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt index 77b2d88..32d925b 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt @@ -20,9 +20,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -42,6 +40,7 @@ import org.libremail.ui.TopDest @Composable fun SettingsScreen( onAddAccount: () -> Unit, + onOpenAccount: (String) -> Unit, onSelectTab: (TopDest) -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { @@ -69,7 +68,7 @@ fun SettingsScreen( ) } else { accounts.forEach { account -> - AccountRow(email = account.email, onRemove = { viewModel.removeAccount(account.id) }) + ClickRow(title = account.email, onClick = { onOpenAccount(account.id) }) } } ClickRow(title = stringResource(R.string.settings_add_account), onClick = onAddAccount) @@ -145,58 +144,6 @@ fun SettingsScreen( } } -@Composable -private fun AccountRow(email: String, onRemove: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(email, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - TextButton(onClick = onRemove) { Text(stringResource(R.string.account_remove)) } - } -} - -@Composable -private fun SectionHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), - ) -} - -@Composable -private fun SwitchRow( - title: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - subtitle: String? = null, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onCheckedChange(!checked) } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.bodyLarge) - if (subtitle != null) { - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Spacer(Modifier.width(16.dp)) - Switch(checked = checked, onCheckedChange = onCheckedChange) - } -} - @Composable private fun RadioRow(title: String, subtitle: String?, selected: Boolean, onClick: () -> Unit) { Row( @@ -221,19 +168,6 @@ private fun RadioRow(title: String, subtitle: String?, selected: Boolean, onClic } } -@Composable -private fun ClickRow(title: String, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - } -} - @Composable private fun AdvancedHeader(expanded: Boolean, onToggle: () -> Unit) { Row( diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt index 341f22c..0d08925 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt @@ -35,8 +35,6 @@ class SettingsViewModel @Inject constructor( fun toggleAdvanced() = _advancedExpanded.update { !it } - fun removeAccount(id: String) = viewModelScope.launch { accountRepository.deleteAccount(id) }.let {} - fun setDynamicColor(value: Boolean) = update { settingsRepository.setDynamicColor(value) } fun setNewMailNotifications(value: Boolean) = update { settingsRepository.setNewMailNotifications(value) } fun setPushIdle(value: Boolean) = update { settingsRepository.setPushIdle(value) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66dca97..a4df47f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -141,7 +141,18 @@ New-mail notifications Show a notification when new mail arrives No accounts yet - Remove + Remove account + + + Account + Signature + Append signature + Your signature + Notifications + New mail notifications + Show notifications for this account + Sound & vibration + Manage sound, vibration, and importance in Android settings Appearance Use wallpaper colors Material You dynamic theming (Android 12+) diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index e792ead..ea67620 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -28,7 +28,9 @@ import org.libremail.data.local.entity.DraftEntity import org.libremail.data.local.entity.FolderEntity import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.ServerConfigEmbedded +import org.libremail.data.settings.AccountSettingsRepository import org.libremail.data.sync.MailConnectionFactory +import org.libremail.domain.model.AccountSettings import org.libremail.domain.model.FolderRole import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity @@ -50,6 +52,7 @@ class MailRepositoryImplTest { private val imapClient = mockk(relaxed = true) private val connectionFactory = mockk() private val context = mockk(relaxed = true) + private val accountSettingsRepository = mockk() private val repository = MailRepositoryImpl( context = context, messageDao = messageDao, @@ -61,6 +64,7 @@ class MailRepositoryImplTest { imapClient = imapClient, connectionFactory = connectionFactory, sendScheduler = mockk(), + accountSettingsRepository = accountSettingsRepository, ) @Test @@ -195,6 +199,7 @@ class MailRepositoryImplTest { val id = "acct:INBOX:2" coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct") coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() coEvery { imapClient.fetchForReply(any(), "INBOX", "2") } returns ReplyContext( fromEmail = "boss@example.org", diff --git a/app/src/test/kotlin/org/libremail/data/settings/AccountSettingsRepositoryTest.kt b/app/src/test/kotlin/org/libremail/data/settings/AccountSettingsRepositoryTest.kt new file mode 100644 index 0000000..55ae60a --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/settings/AccountSettingsRepositoryTest.kt @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.settings + +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.libremail.data.local.dao.AccountSettingsDao +import org.libremail.data.local.entity.AccountSettingsEntity + +class AccountSettingsRepositoryTest { + + private val dao = mockk() + private val repository = AccountSettingsRepository(dao) + + @Test + fun `get returns defaults when no row exists`() = runTest { + coEvery { dao.get("acct") } returns null + + val settings = repository.get("acct") + + assertEquals("acct", settings.accountId) + assertEquals("", settings.signature) + assertTrue(settings.signatureEnabled) + assertTrue(settings.notificationsEnabled) + } + + @Test + fun `setSignature reads, modifies, and writes the row`() = runTest { + coEvery { dao.get("acct") } returns + AccountSettingsEntity("acct", signature = "old", signatureEnabled = true, notificationsEnabled = false) + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.setSignature("acct", "new") + + assertEquals("new", saved.captured.signature) + // Untouched fields are preserved. + assertEquals(false, saved.captured.notificationsEnabled) + } + + @Test + fun `ensureDefaults inserts a default row when none exists`() = runTest { + coEvery { dao.get("acct") } returns null + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.ensureDefaults("acct") + + assertEquals("acct", saved.captured.accountId) + coVerify(exactly = 1) { dao.upsert(any()) } + } + + @Test + fun `ensureDefaults does nothing when a row already exists`() = runTest { + coEvery { dao.get("acct") } returns AccountSettingsEntity("acct") + + repository.ensureDefaults("acct") + + coVerify(exactly = 0) { dao.upsert(any()) } + } +} diff --git a/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt index 0b82440..154b851 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt @@ -15,10 +15,13 @@ import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.entity.AccountEntity import org.libremail.data.local.entity.ServerConfigEmbedded +import org.libremail.data.settings.AccountSettingsRepository import org.libremail.data.settings.FetchPolicy import org.libremail.data.settings.SettingsRepository +import org.libremail.domain.model.AccountSettings import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.repository.MailRepository +import org.libremail.mail.FetchedMessage import org.libremail.mail.ImapClient import org.libremail.notifications.MailNotifier @@ -50,6 +53,8 @@ class MailSyncerTest { coEvery { connectionFactory.imapParamsFor(any()) } returns mockk() val settingsRepository = mockk() coEvery { settingsRepository.fetchPolicy() } returns policy + val accountSettingsRepository = mockk() + coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct") return MailSyncer( context = context, accountDao = accountDao, @@ -57,6 +62,7 @@ class MailSyncerTest { imapClient = imapClient, connectionFactory = connectionFactory, settingsRepository = settingsRepository, + accountSettingsRepository = accountSettingsRepository, notifier = mockk(relaxed = true), mailRepository = mailRepository, ) @@ -100,6 +106,64 @@ class MailSyncerTest { coVerify(exactly = 0) { repo.prefetchMessage(any()) } } + @Test + fun `notifies for new mail when both global and per-account notifications are enabled`() = runTest { + val notifier = mockk(relaxed = true) + notifyingSyncer(globalEnabled = true, accountEnabled = true, notifier = notifier).syncAccount("acct") + + coVerify { notifier.notifyNewMail(any(), any()) } + } + + @Test + fun `does not notify when the account has notifications disabled`() = runTest { + val notifier = mockk(relaxed = true) + notifyingSyncer(globalEnabled = true, accountEnabled = false, notifier = notifier).syncAccount("acct") + + coVerify(exactly = 0) { notifier.notifyNewMail(any(), any()) } + } + + @Test + fun `does not notify when the global master toggle is off`() = runTest { + val notifier = mockk(relaxed = true) + notifyingSyncer(globalEnabled = false, accountEnabled = true, notifier = notifier).syncAccount("acct") + + coVerify(exactly = 0) { notifier.notifyNewMail(any(), any()) } + } + + /** + * A syncer whose INBOX already has a synced row (so it's not a first sync) and whose next fetch + * returns one new unread message — so the notify path is reached, gated only by the toggles. + */ + private fun notifyingSyncer(globalEnabled: Boolean, accountEnabled: Boolean, notifier: MailNotifier): MailSyncer { + val accountDao = mockk() + coEvery { accountDao.getById("acct") } returns account + val messageDao = mockk(relaxed = true) + coEvery { messageDao.getSyncedIds("acct", "INBOX") } returns listOf("acct:INBOX:0") + val imapClient = mockk() + coEvery { imapClient.fetchRecent(any(), any(), any()) } returns listOf( + FetchedMessage("1", "Ada", "ada@example.org", "Hi", 1_000L, isRead = false, isFlagged = false), + ) + val connectionFactory = mockk() + coEvery { connectionFactory.imapParamsFor(any()) } returns mockk() + val settingsRepository = mockk() + coEvery { settingsRepository.isNewMailNotificationsEnabled() } returns globalEnabled + coEvery { settingsRepository.fetchPolicy() } returns FetchPolicy.ON_DEMAND + val accountSettingsRepository = mockk() + coEvery { accountSettingsRepository.get("acct") } returns + AccountSettings("acct", notificationsEnabled = accountEnabled) + return MailSyncer( + context = mockk(relaxed = true), + accountDao = accountDao, + messageDao = messageDao, + imapClient = imapClient, + connectionFactory = connectionFactory, + settingsRepository = settingsRepository, + accountSettingsRepository = accountSettingsRepository, + notifier = notifier, + mailRepository = mockk(relaxed = true), + ) + } + /** A context whose active network reports the given metered state via ConnectivityManager. */ private fun networkContext(unmetered: Boolean): Context { val capabilities = mockk() diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt new file mode 100644 index 0000000..300e569 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose + +import androidx.lifecycle.SavedStateHandle +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlin.test.assertEquals +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.domain.model.Account +import org.libremail.domain.model.AccountSettings +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.Draft +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig +import org.libremail.domain.repository.AccountRepository +import org.libremail.domain.repository.MailRepository +import org.libremail.ui.navigation.Routes + +@OptIn(ExperimentalCoroutinesApi::class) +class ComposeViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(testDispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + private val alice = account("imap:a", "alice@example.org") + private val bob = account("imap:b", "bob@example.org") + + private fun account(id: String, email: String) = Account( + id = id, + email = email, + displayName = email, + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), + ) + + private fun viewModel( + accounts: List = listOf(alice), + savedState: SavedStateHandle = SavedStateHandle(), + signatures: Map = emptyMap(), + mailRepository: MailRepository = mockk(relaxed = true), + ): ComposeViewModel { + val accountRepository = mockk() + every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts) + val accountSettingsRepository = mockk() + coEvery { accountSettingsRepository.get(any()) } answers { + val id = firstArg() + signatures[id] ?: AccountSettings(id) + } + return ComposeViewModel( + savedStateHandle = savedState, + mailRepository = mailRepository, + accountRepository = accountRepository, + contactsRepository = mockk(relaxed = true), + accountSettingsRepository = accountSettingsRepository, + ) + } + + @Test + fun `appends the sending account signature to a new message`() = runTest(testDispatcher) { + val vm = viewModel(signatures = mapOf("imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice"))) + + assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body) + } + + @Test + fun `swaps the signature when the from account changes`() = runTest(testDispatcher) { + val vm = viewModel( + accounts = listOf(alice, bob), + signatures = mapOf( + "imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice"), + "imap:b" to AccountSettings("imap:b", signature = "Best, Bob"), + ), + ) + assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body) + + vm.selectFrom("imap:b") + + assertEquals("\n\n-- \nBest, Bob", vm.state.value.body) + } + + @Test + fun `does not append a signature when resuming a draft`() = runTest(testDispatcher) { + val mailRepository = mockk(relaxed = true) + coEvery { mailRepository.getDraft("d1") } returns Draft( + id = "d1", + accountId = "imap:a", + to = "x@example.org", + cc = "", + subject = "Hi", + body = "Draft body", + updatedAt = 0L, + attachments = emptyList(), + ) + val vm = viewModel( + savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1")), + signatures = mapOf("imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice")), + mailRepository = mailRepository, + ) + + assertEquals("Draft body", vm.state.value.body) + } +}