feat: per-account settings for signatures and notifications #7

Merged
JMR-dev merged 2 commits from feat-per-account-settings into main 2026-07-01 01:28:54 +00:00
30 changed files with 1497 additions and 108 deletions
@@ -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')"
]
}
}
@@ -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()
@@ -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) {
@@ -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<ComponentActivity>()
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<Context>()
// 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 }
}
}
}
@@ -39,7 +39,7 @@ class SettingsScreenTest {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
SettingsScreen(onAddAccount = {}, onSelectTab = {}, viewModel = viewModel)
SettingsScreen(onAddAccount = {}, onOpenAccount = {}, onSelectTab = {}, viewModel = viewModel)
}
}
@@ -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
@@ -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,
@@ -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`",
)
}
}
@@ -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<AccountSettingsEntity?>
@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)
}
@@ -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,
)
@@ -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<List<Account>> =
@@ -37,7 +41,9 @@ class AccountRepositoryImpl @Inject constructor(
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> = 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)
}
@@ -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<List<Message>> =
@@ -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(),
),
@@ -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<AccountSettings> =
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())
}
}
@@ -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
@@ -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()
@@ -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 ""
}
@@ -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<MessageEntity>) {
fun notifyNewMail(account: Account, messages: List<MessageEntity>) {
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()
}
}
@@ -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,
@@ -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<OutgoingAttachment>) =
_state.update { it.copy(attachments = it.attachments + items) }
fun removeAttachment(uri: String) =
@@ -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"
@@ -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),
)
}
}
}
@@ -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<String>(Routes.ACCOUNT_SETTINGS_ARG_ID))
val account: StateFlow<Account?> = accountRepository.observeAccounts()
.map { list -> list.firstOrNull { it.id == accountId } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val settings: StateFlow<AccountSettings> = 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<String?>(null)
val signature: StateFlow<String?> = _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()
}
}
}
@@ -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,
)
}
}
}
}
@@ -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(
@@ -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) }
+12 -1
View File
@@ -141,7 +141,18 @@
<string name="settings_new_mail">New-mail notifications</string>
<string name="settings_new_mail_summary">Show a notification when new mail arrives</string>
<string name="settings_no_accounts">No accounts yet</string>
<string name="account_remove">Remove</string>
<string name="account_remove">Remove account</string>
<!-- Per-account settings -->
<string name="settings_account_title">Account</string>
<string name="settings_signature">Signature</string>
<string name="settings_signature_enable">Append signature</string>
<string name="settings_signature_hint">Your signature</string>
<string name="settings_account_notifications">Notifications</string>
<string name="settings_account_new_mail">New mail notifications</string>
<string name="settings_account_new_mail_summary">Show notifications for this account</string>
<string name="settings_account_system_notif">Sound &amp; vibration</string>
<string name="settings_account_system_notif_summary">Manage sound, vibration, and importance in Android settings</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_dynamic_color">Use wallpaper colors</string>
<string name="settings_dynamic_color_summary">Material You dynamic theming (Android 12+)</string>
@@ -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<ImapClient>(relaxed = true)
private val connectionFactory = mockk<MailConnectionFactory>()
private val context = mockk<Context>(relaxed = true)
private val accountSettingsRepository = mockk<AccountSettingsRepository>()
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",
@@ -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<AccountSettingsDao>()
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<AccountSettingsEntity>()
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<AccountSettingsEntity>()
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()) }
}
}
@@ -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<ImapConnectionParams>()
val settingsRepository = mockk<SettingsRepository>()
coEvery { settingsRepository.fetchPolicy() } returns policy
val accountSettingsRepository = mockk<AccountSettingsRepository>()
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<MailNotifier>(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<MailNotifier>(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<MailNotifier>(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<MailNotifier>(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<AccountDao>()
coEvery { accountDao.getById("acct") } returns account
val messageDao = mockk<MessageDao>(relaxed = true)
coEvery { messageDao.getSyncedIds("acct", "INBOX") } returns listOf("acct:INBOX:0")
val imapClient = mockk<ImapClient>()
coEvery { imapClient.fetchRecent(any(), any(), any()) } returns listOf(
FetchedMessage("1", "Ada", "ada@example.org", "Hi", 1_000L, isRead = false, isFlagged = false),
)
val connectionFactory = mockk<MailConnectionFactory>()
coEvery { connectionFactory.imapParamsFor(any()) } returns mockk<ImapConnectionParams>()
val settingsRepository = mockk<SettingsRepository>()
coEvery { settingsRepository.isNewMailNotificationsEnabled() } returns globalEnabled
coEvery { settingsRepository.fetchPolicy() } returns FetchPolicy.ON_DEMAND
val accountSettingsRepository = mockk<AccountSettingsRepository>()
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<NetworkCapabilities>()
@@ -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<Account> = listOf(alice),
savedState: SavedStateHandle = SavedStateHandle(),
signatures: Map<String, AccountSettings> = emptyMap(),
mailRepository: MailRepository = mockk(relaxed = true),
): ComposeViewModel {
val accountRepository = mockk<AccountRepository>()
every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts)
val accountSettingsRepository = mockk<AccountSettingsRepository>()
coEvery { accountSettingsRepository.get(any()) } answers {
val id = firstArg<String>()
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<MailRepository>(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)
}
}