feat: per-account settings for signatures and notifications #7
@@ -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) }
|
||||
|
||||
@@ -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 & 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user