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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user