Add message reading: body fetch + hardened WebView + flag actions

Increment 4 — read.

- ImapClient.fetchBodyMarkingSeen extracts the best body part (HTML preferred,
  else plain text) and marks the message \Seen; setFlag and deleteMessage (expunge)
  back the star/read/delete actions.
- MailConnectionFactory shares credential/token resolution between sync and reader.
- Cached bodies survive sync: schema v3 (isHtml column via a data-preserving
  Migration 2->3); sync is now insert-new + update-header + delete-absent instead of
  replace-all, so fetched bodies are not clobbered.
- Reader fetches and caches the body on open (marking it read), renders HTML in a
  hardened WebView (JavaScript off, file/content access off, remote content blocked
  with an opt-in "Show images") and plain text in selectable Text; star + delete in
  the app bar; a snippet is derived from the fetched body.
- Tests: GreenMail fetchBodyMarkingSeen unit test (body + read flag). assemble/test/
  lint green; verified end-to-end on the Android 17 emulator against a local GreenMail
  server (HTML rendered in the WebView, mark-read, snippet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 19:05:55 -05:00
co-authored by Claude Opus 4.8
parent 5386ae76d3
commit d81e94717b
19 changed files with 524 additions and 104 deletions
+4 -3
View File
@@ -7,9 +7,10 @@ experience with power-user features tucked under an **Advanced Settings** group.
> Status: **in development.** Material You shell; **account setup** (Gmail OAuth via
> AppAuth/PKCE and generic IMAP/SMTP, with a live connection test and Keystore-
> encrypted credentials); and **IMAP receive** — background sync (WorkManager) that
> fetches your inbox headers into a local Room cache, shown with pull-to-refresh.
> Reading message bodies and sending land in upcoming increments.
> encrypted credentials); **IMAP receive** — background sync (WorkManager) into a local
> Room cache with pull-to-refresh; and **reading** — message bodies fetched on open and
> rendered in a hardened WebView (JavaScript off, remote images blocked by default),
> with mark-read, star, and delete. Composing and sending land next.
## Features (target MVP)
@@ -12,7 +12,7 @@ import org.libremail.data.local.entity.MessageEntity
@Database(
entities = [AccountEntity::class, MessageEntity::class, CredentialEntity::class],
version = 2,
version = 3,
exportSchema = false,
)
abstract class LibreMailDatabase : RoomDatabase() {
@@ -48,6 +48,7 @@ internal fun MessageEntity.toDomain(): Message = Message(
subject = subject,
snippet = snippet,
body = body,
isHtml = isHtml,
timestampMillis = timestampMillis,
isRead = isRead,
isStarred = isStarred,
@@ -61,6 +62,7 @@ internal fun FetchedMessage.toEntity(accountId: String): MessageEntity = Message
subject = subject,
snippet = "",
body = "",
isHtml = false,
timestampMillis = timestampMillis,
isRead = isRead,
isStarred = isFlagged,
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/** v2 -> v3: add the [isHtml] flag to cached messages (preserves existing data). */
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE messages ADD COLUMN isHtml INTEGER NOT NULL DEFAULT 0")
}
}
@@ -5,28 +5,51 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.MessageEntity
@Dao
abstract class MessageDao {
interface MessageDao {
@Query("SELECT * FROM messages ORDER BY timestampMillis DESC")
abstract fun observeAll(): Flow<List<MessageEntity>>
fun observeAll(): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
abstract suspend fun getById(id: String): MessageEntity?
suspend fun getById(id: String): MessageEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun upsertAll(messages: List<MessageEntity>)
/** Inserts only new messages, leaving existing rows (and their cached bodies) intact. */
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertNew(messages: List<MessageEntity>)
/** Refreshes header/flag columns from the server without touching the cached body. */
@Query(
"UPDATE messages SET sender = :sender, senderEmail = :senderEmail, subject = :subject, " +
"timestampMillis = :timestampMillis, isRead = :isRead, isStarred = :isStarred WHERE id = :id",
)
suspend fun updateHeader(
id: String,
sender: String,
senderEmail: String,
subject: String,
timestampMillis: Long,
isRead: Boolean,
isStarred: Boolean,
)
@Query("UPDATE messages SET body = :body, isHtml = :isHtml, snippet = :snippet WHERE id = :id")
suspend fun updateBody(id: String, body: String, isHtml: Boolean, snippet: String)
@Query("UPDATE messages SET isRead = :isRead WHERE id = :id")
suspend fun setRead(id: String, isRead: Boolean)
@Query("UPDATE messages SET isStarred = :isStarred WHERE id = :id")
suspend fun setStarred(id: String, isStarred: Boolean)
@Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String)
@Query("DELETE FROM messages WHERE accountId = :accountId")
abstract suspend fun deleteByAccount(accountId: String)
suspend fun deleteByAccount(accountId: String)
/** Replace an account's cached messages with a freshly fetched set, atomically. */
@Transaction
open suspend fun replaceAccountMessages(accountId: String, messages: List<MessageEntity>) {
deleteByAccount(accountId)
upsertAll(messages)
}
@Query("DELETE FROM messages WHERE accountId = :accountId AND id NOT IN (:keepIds)")
suspend fun deleteNotIn(accountId: String, keepIds: List<String>)
}
@@ -17,6 +17,7 @@ data class MessageEntity(
val subject: String,
val snippet: String,
val body: String,
val isHtml: Boolean = false,
val timestampMillis: Long,
val isRead: Boolean,
val isStarred: Boolean,
@@ -1,23 +1,73 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.repository
import jakarta.mail.Flags
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.toDomain
import org.libremail.data.sync.MailConnectionFactory
import org.libremail.domain.model.Account
import org.libremail.domain.model.Message
import org.libremail.domain.repository.MailRepository
import org.libremail.mail.ImapClient
@Singleton
class MailRepositoryImpl @Inject constructor(
private val messageDao: MessageDao,
private val accountDao: AccountDao,
private val imapClient: ImapClient,
private val connectionFactory: MailConnectionFactory,
) : MailRepository {
override fun observeMessages(): Flow<List<Message>> =
messageDao.observeAll().map { rows -> rows.map { it.toDomain() } }
override suspend fun getMessage(id: String): Message? =
messageDao.getById(id)?.toDomain()
override suspend fun getMessage(id: String): Message? = messageDao.getById(id)?.toDomain()
override suspend fun openMessage(id: String): Result<Message> = runCatching {
val entity = messageDao.getById(id) ?: error("Message not found")
val account = accountDao.getById(entity.accountId)?.toDomain()
if (account != null) {
val params = connectionFactory.paramsFor(account)
if (entity.body.isBlank()) {
val content = imapClient.fetchBodyMarkingSeen(params, uidOf(id))
messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body))
messageDao.setRead(id, true)
} else if (!entity.isRead) {
runCatching { imapClient.setFlag(params, uidOf(id), Flags.Flag.SEEN, true) }
messageDao.setRead(id, true)
}
}
messageDao.getById(id)?.toDomain() ?: error("Message not found")
}
override suspend fun setStarred(id: String, starred: Boolean): Result<Unit> = runCatching {
messageDao.setStarred(id, starred) // optimistic; next sync reconciles on failure
accountFor(id)?.let { account ->
imapClient.setFlag(connectionFactory.paramsFor(account), uidOf(id), Flags.Flag.FLAGGED, starred)
}
Unit
}
override suspend fun deleteMessage(id: String): Result<Unit> = runCatching {
val account = accountFor(id)
messageDao.deleteById(id) // optimistic; reappears on next sync if the server delete failed
account?.let { imapClient.deleteMessage(connectionFactory.paramsFor(it), uidOf(id)) }
Unit
}
private suspend fun accountFor(id: String): Account? {
val entity = messageDao.getById(id) ?: return null
return accountDao.getById(entity.accountId)?.toDomain()
}
}
/** Message id is "<accountId>:<uid>"; the uid is the trailing segment. */
private fun uidOf(id: String): String = id.substringAfterLast(':')
private fun snippetOf(body: String): String =
body.replace(Regex("<[^>]*>"), " ").replace(Regex("\\s+"), " ").trim().take(140)
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import javax.inject.Inject
import javax.inject.Singleton
import org.libremail.auth.GmailAuthManager
import org.libremail.data.local.toImapParams
import org.libremail.data.security.CredentialStore
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.ImapConnectionParams
/** Resolves an account's stored credential (refreshing the Gmail token when needed) into IMAP params. */
@Singleton
class MailConnectionFactory @Inject constructor(
private val credentialStore: CredentialStore,
private val gmailAuthManager: GmailAuthManager,
) {
suspend fun paramsFor(account: Account): ImapConnectionParams {
val stored = credentialStore.loadSecret(account.id)
?: error("No stored credentials for ${account.email}")
val secret = when (account.authType) {
AuthType.PASSWORD_IMAP -> stored
AuthType.OAUTH_GMAIL -> {
val fresh = gmailAuthManager.freshAccessToken(stored)
if (fresh.authStateJson != stored) {
credentialStore.saveSecret(account.id, fresh.authStateJson)
}
fresh.accessToken
}
}
return account.toImapParams(secret, useXoauth2 = account.authType == AuthType.OAUTH_GMAIL)
}
}
@@ -3,15 +3,11 @@ package org.libremail.data.sync
import javax.inject.Inject
import javax.inject.Singleton
import org.libremail.auth.GmailAuthManager
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.local.toImapParams
import org.libremail.data.security.CredentialStore
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.mail.ImapClient
/** Fetches each account's recent INBOX headers and writes them into Room (the source of truth). */
@@ -19,9 +15,8 @@ import org.libremail.mail.ImapClient
class MailSyncer @Inject constructor(
private val accountDao: AccountDao,
private val messageDao: MessageDao,
private val credentialStore: CredentialStore,
private val imapClient: ImapClient,
private val gmailAuthManager: GmailAuthManager,
private val connectionFactory: MailConnectionFactory,
) {
/** Syncs every account. Succeeds if at least one account synced (or there are none). */
suspend fun syncAll(): Result<Int> {
@@ -41,29 +36,29 @@ class MailSyncer @Inject constructor(
}
private suspend fun syncAccount(account: Account): Result<Int> = runCatching {
val secret = resolveSecret(account)
val fetched = imapClient.fetchRecentInbox(
account.toImapParams(secret = secret, useXoauth2 = account.authType == AuthType.OAUTH_GMAIL),
INBOX_LIMIT,
)
messageDao.replaceAccountMessages(account.id, fetched.map { it.toEntity(account.id) })
fetched.size
}
/** Returns a usable IMAP secret, refreshing and re-persisting the OAuth token when needed. */
private suspend fun resolveSecret(account: Account): String {
val stored = credentialStore.loadSecret(account.id)
?: error("No stored credentials for ${account.email}")
return when (account.authType) {
AuthType.PASSWORD_IMAP -> stored
AuthType.OAUTH_GMAIL -> {
val fresh = gmailAuthManager.freshAccessToken(stored)
if (fresh.authStateJson != stored) {
credentialStore.saveSecret(account.id, fresh.authStateJson)
}
fresh.accessToken
val params = connectionFactory.paramsFor(account)
val fetched = imapClient.fetchRecentInbox(params, INBOX_LIMIT)
val entities = fetched.map { it.toEntity(account.id) }
if (entities.isEmpty()) {
messageDao.deleteByAccount(account.id)
} else {
// Insert new headers (keeps any already-cached body), refresh header/flag columns,
// then drop messages that no longer exist on the server.
messageDao.insertNew(entities)
entities.forEach {
messageDao.updateHeader(
id = it.id,
sender = it.sender,
senderEmail = it.senderEmail,
subject = it.subject,
timestampMillis = it.timestampMillis,
isRead = it.isRead,
isStarred = it.isStarred,
)
}
messageDao.deleteNotIn(account.id, entities.map { it.id })
}
fetched.size
}
private companion object {
@@ -10,6 +10,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.MIGRATION_2_3
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.CredentialDao
import org.libremail.data.local.dao.MessageDao
@@ -22,7 +23,8 @@ object DatabaseModule {
@Singleton
fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase =
Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db")
// MVP: no user data worth migrating yet.
.addMigrations(MIGRATION_2_3)
// Safety net for unforeseen schema jumps during early development.
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -9,6 +9,7 @@ data class Message(
val subject: String,
val snippet: String,
val body: String,
val isHtml: Boolean,
val timestampMillis: Long,
val isRead: Boolean,
val isStarred: Boolean,
@@ -12,4 +12,11 @@ interface MailRepository {
fun observeMessages(): Flow<List<Message>>
suspend fun getMessage(id: String): Message?
/** Loads a message for reading: fetches+caches the body if missing, and marks it read. */
suspend fun openMessage(id: String): Result<Message>
suspend fun setStarred(id: String, starred: Boolean): Result<Unit>
suspend fun deleteMessage(id: String): Result<Unit>
}
@@ -4,6 +4,8 @@ package org.libremail.mail
import jakarta.mail.FetchProfile
import jakarta.mail.Flags
import jakarta.mail.Folder
import jakarta.mail.Multipart
import jakarta.mail.Part
import jakarta.mail.Session
import jakarta.mail.Store
import jakarta.mail.UIDFolder
@@ -27,6 +29,12 @@ data class FetchedMessage(
val isFlagged: Boolean,
)
/** A message body extracted from the server. */
data class MessageContent(
val body: String,
val isHtml: Boolean,
)
/** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */
@Singleton
class ImapClient @Inject constructor() {
@@ -77,6 +85,70 @@ class ImapClient @Inject constructor() {
}
}
/** Fetches a message body by UID and marks it \Seen on the server. */
suspend fun fetchBodyMarkingSeen(params: ImapConnectionParams, uid: String): MessageContent =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_WRITE)
try {
val message = (inbox as UIDFolder).getMessageByUID(uid.toLong())
?: error("Message $uid not found")
val content = extractBody(message) ?: MessageContent("", isHtml = false)
message.setFlag(Flags.Flag.SEEN, true)
content
} finally {
runCatching { inbox.close(false) }
}
}
}
suspend fun setFlag(params: ImapConnectionParams, uid: String, flag: Flags.Flag, value: Boolean) =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_WRITE)
try {
(inbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(flag, value)
} finally {
runCatching { inbox.close(false) }
}
}
}
suspend fun deleteMessage(params: ImapConnectionParams, uid: String) =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_WRITE)
try {
(inbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(Flags.Flag.DELETED, true)
inbox.expunge()
} finally {
runCatching { inbox.close(false) }
}
}
}
/** Recursively finds the best body part: HTML preferred, plain text otherwise. */
private fun extractBody(part: Part): MessageContent? {
if (part.isMimeType("text/html")) return MessageContent(part.content.toString(), isHtml = true)
if (part.isMimeType("text/plain")) return MessageContent(part.content.toString(), isHtml = false)
if (part.isMimeType("multipart/*")) {
val multipart = part.content as? Multipart ?: return null
var plain: MessageContent? = null
for (i in 0 until multipart.count) {
val child = multipart.getBodyPart(i)
if (Part.ATTACHMENT.equals(child.disposition, ignoreCase = true)) continue
val result = extractBody(child) ?: continue
if (result.isHtml) return result
if (plain == null) plain = result
}
return plain
}
return null
}
private inline fun <T> withStore(params: ImapConnectionParams, block: (Store) -> T): T {
val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap"
val props = Properties().apply {
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reader
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
/**
* Renders an HTML email body in a hardened WebView: JavaScript and file/content access are
* disabled, links open in the system browser, and remote content is blocked until the user
* opts in (tracking-pixel protection).
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun HtmlBody(
html: String,
loadRemoteImages: Boolean,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
AndroidView(
modifier = modifier,
factory = { ctx ->
WebView(ctx).apply {
with(settings) {
javaScriptEnabled = false
allowFileAccess = false
allowContentAccess = false
domStorageEnabled = false
cacheMode = WebSettings.LOAD_NO_CACHE
setSupportZoom(true)
builtInZoomControls = true
displayZoomControls = false
}
setBackgroundColor(Color.TRANSPARENT)
isVerticalScrollBarEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url ?: return false
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url)) }
return true
}
}
}
},
update = { webView ->
webView.settings.blockNetworkLoads = !loadRemoteImages
webView.loadDataWithBaseURL(null, wrapHtml(html), "text/html", "UTF-8", null)
},
)
}
private fun wrapHtml(body: String): String =
"""
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; line-height: 1.5; padding: 16px; word-wrap: break-word; }
img { max-width: 100%; height: auto; }
a { color: #0B57D0; }
</style>
</head>
<body>$body</body>
</html>
""".trimIndent()
@@ -4,18 +4,23 @@ package org.libremail.ui.reader
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -24,8 +29,10 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -35,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.domain.model.Message
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -42,69 +50,150 @@ fun ReaderScreen(
onBack: () -> Unit,
viewModel: ReaderViewModel = hiltViewModel(),
) {
val message by viewModel.message.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.deleted) {
if (state.deleted) onBack()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.title_reader)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
val message = state.message
if (message != null) {
IconButton(onClick = viewModel::toggleStar) {
Icon(
Icons.Filled.Star,
contentDescription = stringResource(R.string.reader_star),
tint = if (message.isStarred) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
IconButton(onClick = viewModel::delete) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.reader_delete))
}
}
},
)
},
) { padding ->
val msg = message
if (msg == null) {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) {
val message = state.message
when {
state.loading -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(msg.subject, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = msg.sender.trim().firstOrNull()?.uppercase() ?: "?",
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(Modifier.width(12.dp))
Column {
Text(msg.sender, style = MaterialTheme.typography.titleMedium)
Text(
msg.senderEmail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
Text(msg.body, style = MaterialTheme.typography.bodyLarge)
message != null -> MessageBody(
message = message,
loadRemoteImages = state.loadRemoteImages,
onLoadRemoteImages = viewModel::loadRemoteImages,
contentPadding = padding,
)
else -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(state.error ?: stringResource(R.string.reader_empty))
}
}
}
}
@Composable
private fun MessageBody(
message: Message,
loadRemoteImages: Boolean,
onLoadRemoteImages: () -> Unit,
contentPadding: PaddingValues,
) {
Column(Modifier.fillMaxSize().padding(contentPadding)) {
Header(message)
HorizontalDivider()
if (message.isHtml && !loadRemoteImages) {
RemoteImagesBanner(onLoadRemoteImages)
}
when {
message.body.isBlank() -> Text(
stringResource(R.string.reader_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
message.isHtml -> HtmlBody(
html = message.body,
loadRemoteImages = loadRemoteImages,
modifier = Modifier.fillMaxSize(),
)
else -> SelectionContainer(modifier = Modifier.fillMaxSize()) {
Text(
text = message.body,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
)
}
}
}
}
@Composable
private fun Header(message: Message) {
Column(Modifier.padding(16.dp)) {
Text(message.subject, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = message.sender.trim().firstOrNull()?.uppercase() ?: "?",
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(Modifier.width(12.dp))
Column {
Text(message.sender, style = MaterialTheme.typography.titleMedium)
Text(
message.senderEmail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun RemoteImagesBanner(onLoadRemoteImages: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.reader_images_blocked),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onLoadRemoteImages) {
Text(stringResource(R.string.reader_show_images))
}
}
}
@@ -6,22 +6,56 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.domain.model.Message
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
data class ReaderUiState(
val loading: Boolean = true,
val message: Message? = null,
val loadRemoteImages: Boolean = false,
val deleted: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class ReaderViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: MailRepository,
) : ViewModel() {
private val messageId: String = checkNotNull(savedStateHandle[Routes.READER_ARG_ID])
val message: StateFlow<Message?> = flow {
emit(repository.getMessage(messageId))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _state = MutableStateFlow(ReaderUiState())
val state: StateFlow<ReaderUiState> = _state.asStateFlow()
init {
viewModelScope.launch {
repository.openMessage(messageId).fold(
onSuccess = { message -> _state.update { it.copy(loading = false, message = message) } },
onFailure = { e -> _state.update { it.copy(loading = false, error = e.message ?: "Could not load message") } },
)
}
}
fun toggleStar() {
val message = _state.value.message ?: return
val starred = !message.isStarred
_state.update { it.copy(message = message.copy(isStarred = starred)) }
viewModelScope.launch { repository.setStarred(messageId, starred) }
}
fun loadRemoteImages() = _state.update { it.copy(loadRemoteImages = true) }
fun delete() {
viewModelScope.launch {
repository.deleteMessage(messageId)
_state.update { it.copy(deleted = true) }
}
}
}
+5
View File
@@ -6,6 +6,11 @@
<!-- Destinations -->
<string name="title_mailbox">Inbox</string>
<string name="title_reader">Message</string>
<string name="reader_star">Star</string>
<string name="reader_delete">Delete</string>
<string name="reader_empty">This message has no text content</string>
<string name="reader_images_blocked">Remote images are blocked</string>
<string name="reader_show_images">Show images</string>
<string name="title_compose">Compose</string>
<string name="title_settings">Settings</string>
<string name="title_account_setup">Add account</string>
@@ -15,11 +15,17 @@ import org.libremail.data.local.entity.MessageEntity
class MailRepositoryImplTest {
private val messageDao = mockk<MessageDao>()
private val repository = MailRepositoryImpl(
messageDao = messageDao,
accountDao = mockk(),
imapClient = mockk(),
connectionFactory = mockk(),
)
@Test
fun `observeMessages is empty when the cache is empty`() = runTest {
every { messageDao.observeAll() } returns flowOf(emptyList())
MailRepositoryImpl(messageDao).observeMessages().test {
repository.observeMessages().test {
assertTrue(awaitItem().isEmpty())
awaitComplete()
}
@@ -40,7 +46,7 @@ class MailRepositoryImplTest {
isStarred = false,
)
every { messageDao.observeAll() } returns flowOf(listOf(entity))
MailRepositoryImpl(messageDao).observeMessages().test {
repository.observeMessages().test {
val items = awaitItem()
assertEquals(1, items.size)
assertEquals("Ada", items.first().sender)
@@ -63,4 +63,16 @@ class ImapClientTest {
assertEquals(setOf("First subject", "Second subject"), messages.map { it.subject }.toSet())
assertEquals("bob@example.org", messages.first { it.subject == "First subject" }.senderEmail)
}
@Test
fun `fetchBodyMarkingSeen returns the body and marks the message read`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Hello", "The quick brown fox.")
greenMail.waitForIncomingEmail(1)
val uid = client.fetchRecentInbox(params(), limit = 50).first().uid
val content = client.fetchBodyMarkingSeen(params(), uid)
assertTrue(content.body.contains("quick brown fox"), "body=${content.body}")
assertTrue(client.fetchRecentInbox(params(), limit = 50).first().isRead, "should be marked read")
}
}