diff --git a/README.md b/README.md
index 7c745b1..85e4a1e 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,9 @@ experience with power-user features tucked under an **Advanced Settings** group.
> rendered in a hardened WebView (JavaScript off, remote images blocked by default),
> with mark-read, star, and delete; and **composing** — a compose screen with device-
> contacts autocomplete that sends over SMTP, plus reply; **on-device new-mail
-> notifications** (no push service) with persisted settings; and **instant push** via a
-> foreground **IMAP IDLE** service. Outlook Graph send, attachments, and multi-account
-> polish are upcoming.
+> notifications** (no push service) with persisted settings; **instant push** via a
+> foreground **IMAP IDLE** service; and **attachments** — downloaded on demand and opened
+> in a system viewer. Outlook Graph send and multi-account polish are upcoming.
## Features (target MVP)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e9a012d..6f69c0a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -47,5 +47,16 @@
android:name=".push.IdleService"
android:exported="false"
android:foregroundServiceType="dataSync" />
+
+
+
+
+
diff --git a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt
index ccb0e16..a4b4a21 100644
--- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt
+++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt
@@ -4,19 +4,22 @@ 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.AttachmentDao
import org.libremail.data.local.dao.CredentialDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.entity.AccountEntity
+import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.CredentialEntity
import org.libremail.data.local.entity.MessageEntity
@Database(
- entities = [AccountEntity::class, MessageEntity::class, CredentialEntity::class],
- version = 3,
+ entities = [AccountEntity::class, MessageEntity::class, CredentialEntity::class, AttachmentEntity::class],
+ version = 4,
exportSchema = false,
)
abstract class LibreMailDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
abstract fun accountDao(): AccountDao
abstract fun credentialDao(): CredentialDao
+ abstract fun attachmentDao(): AttachmentDao
}
diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt
index 8fe6cf7..7bddea3 100644
--- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt
+++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt
@@ -2,15 +2,18 @@
package org.libremail.data.local
import org.libremail.data.local.entity.AccountEntity
+import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.domain.model.Account
+import org.libremail.domain.model.Attachment
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.Message
import org.libremail.domain.model.ServerConfig
import org.libremail.domain.model.SmtpParams
+import org.libremail.mail.AttachmentPart
import org.libremail.mail.FetchedMessage
internal fun AccountEntity.toDomain(): Account = Account(
@@ -79,5 +82,21 @@ internal fun FetchedMessage.toEntity(accountId: String): MessageEntity = Message
isStarred = isFlagged,
)
+internal fun AttachmentEntity.toDomain(): Attachment = Attachment(
+ messageId = messageId,
+ partIndex = partIndex,
+ filename = filename,
+ mimeType = mimeType,
+ sizeBytes = sizeBytes,
+)
+
+internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = AttachmentEntity(
+ messageId = messageId,
+ partIndex = partIndex,
+ filename = filename,
+ mimeType = mimeType,
+ sizeBytes = sizeBytes,
+)
+
private fun String.toMailSecurity(): MailSecurity =
runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS)
diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt
index bc9feb8..ae265f7 100644
--- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt
+++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt
@@ -10,3 +10,18 @@ val MIGRATION_2_3 = object : Migration(2, 3) {
db.execSQL("ALTER TABLE messages ADD COLUMN isHtml INTEGER NOT NULL DEFAULT 0")
}
}
+
+/** v3 -> v4: add the attachments metadata table (preserves existing data). */
+val MIGRATION_3_4 = object : Migration(3, 4) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS `attachments` (" +
+ "`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, " +
+ "`mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, " +
+ "PRIMARY KEY(`messageId`, `partIndex`))",
+ )
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `attachments` (`messageId`)",
+ )
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt
new file mode 100644
index 0000000..b016c6e
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt
@@ -0,0 +1,29 @@
+// 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 androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
+import org.libremail.data.local.entity.AttachmentEntity
+
+@Dao
+interface AttachmentDao {
+ @Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex")
+ fun observeForMessage(messageId: String): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(attachments: List)
+
+ @Query("DELETE FROM attachments WHERE messageId = :messageId")
+ suspend fun deleteForMessage(messageId: String)
+
+ /** Replaces the cached attachment list for a message in one transaction. */
+ @Transaction
+ suspend fun replaceForMessage(messageId: String, attachments: List) {
+ deleteForMessage(messageId)
+ insert(attachments)
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt
new file mode 100644
index 0000000..9713c14
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt
@@ -0,0 +1,19 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.Index
+
+/** Cached metadata for one attachment part of a message (the bytes are fetched on demand). */
+@Entity(
+ tableName = "attachments",
+ primaryKeys = ["messageId", "partIndex"],
+ indices = [Index("messageId")],
+)
+data class AttachmentEntity(
+ val messageId: String,
+ val partIndex: Int,
+ val filename: String,
+ val mimeType: String,
+ val sizeBytes: Long,
+)
diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt
index c6a2ded..082b57f 100644
--- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt
+++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt
@@ -1,26 +1,35 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.repository
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.mail.Flags
+import java.io.File
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.AttachmentDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.toDomain
+import org.libremail.data.local.toEntity
import org.libremail.data.sync.MailConnectionFactory
import org.libremail.domain.model.Account
+import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.repository.MailRepository
+import org.libremail.mail.DownloadedAttachment
import org.libremail.mail.ImapClient
import org.libremail.mail.SmtpSender
@Singleton
class MailRepositoryImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
private val messageDao: MessageDao,
private val accountDao: AccountDao,
+ private val attachmentDao: AttachmentDao,
private val imapClient: ImapClient,
private val smtpSender: SmtpSender,
private val connectionFactory: MailConnectionFactory,
@@ -39,6 +48,7 @@ class MailRepositoryImpl @Inject constructor(
if (entity.body.isBlank()) {
val content = imapClient.fetchBodyMarkingSeen(params, uidOf(id))
messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body))
+ attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) })
messageDao.setRead(id, true)
} else if (!entity.isRead) {
runCatching { imapClient.setFlag(params, uidOf(id), Flags.Flag.SEEN, true) }
@@ -48,6 +58,16 @@ class MailRepositoryImpl @Inject constructor(
messageDao.getById(id)?.toDomain() ?: error("Message not found")
}
+ override fun observeAttachments(messageId: String): Flow> =
+ attachmentDao.observeForMessage(messageId).map { rows -> rows.map { it.toDomain() } }
+
+ override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result = runCatching {
+ val entity = messageDao.getById(messageId) ?: error("Message not found")
+ val account = accountDao.getById(entity.accountId)?.toDomain() ?: error("Account not found")
+ val params = connectionFactory.imapParamsFor(account)
+ saveToCache(imapClient.fetchAttachment(params, uidOf(messageId), partIndex))
+ }
+
override suspend fun setStarred(id: String, starred: Boolean): Result = runCatching {
messageDao.setStarred(id, starred) // optimistic; next sync reconciles on failure
accountFor(id)?.let { account ->
@@ -68,6 +88,15 @@ class MailRepositoryImpl @Inject constructor(
smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = outgoing)
}
+ /** Writes downloaded bytes to a private cache file that the FileProvider can share. */
+ private fun saveToCache(attachment: DownloadedAttachment): File {
+ val dir = File(context.cacheDir, "attachments").apply { mkdirs() }
+ val safeName = attachment.filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
+ return File(dir, safeName).also { file ->
+ file.outputStream().use { it.write(attachment.bytes) }
+ }
+ }
+
private suspend fun accountFor(id: String): Account? {
val entity = messageDao.getById(id) ?: return null
return accountDao.getById(entity.accountId)?.toDomain()
diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt
index 7a5a6b9..3c68913 100644
--- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt
+++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt
@@ -11,7 +11,9 @@ 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.MIGRATION_3_4
import org.libremail.data.local.dao.AccountDao
+import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.CredentialDao
import org.libremail.data.local.dao.MessageDao
@@ -23,7 +25,7 @@ object DatabaseModule {
@Singleton
fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase =
Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db")
- .addMigrations(MIGRATION_2_3)
+ .addMigrations(MIGRATION_2_3, MIGRATION_3_4)
// Safety net for unforeseen schema jumps during early development.
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -36,4 +38,7 @@ object DatabaseModule {
@Provides
fun provideCredentialDao(database: LibreMailDatabase): CredentialDao = database.credentialDao()
+
+ @Provides
+ fun provideAttachmentDao(database: LibreMailDatabase): AttachmentDao = database.attachmentDao()
}
diff --git a/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt b/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt
new file mode 100644
index 0000000..bcb3932
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt
@@ -0,0 +1,10 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.domain.model
+
+data class Attachment(
+ val messageId: String,
+ val partIndex: Int,
+ val filename: String,
+ val mimeType: String,
+ val sizeBytes: Long,
+)
diff --git a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt
index f7fcb15..6bce7bf 100644
--- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt
+++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.repository
+import java.io.File
import kotlinx.coroutines.flow.Flow
+import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutgoingMessage
@@ -17,6 +19,12 @@ interface MailRepository {
/** Loads a message for reading: fetches+caches the body if missing, and marks it read. */
suspend fun openMessage(id: String): Result
+ /** Cached attachment metadata for a message, populated when the message is opened. */
+ fun observeAttachments(messageId: String): Flow>
+
+ /** Downloads an attachment's bytes to a local cache file and returns it. */
+ suspend fun downloadAttachment(messageId: String, partIndex: Int): Result
+
suspend fun setStarred(id: String, starred: Boolean): Result
suspend fun deleteMessage(id: String): Result
diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt
index d232967..5515db5 100644
--- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt
+++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt
@@ -12,7 +12,9 @@ import jakarta.mail.Store
import jakarta.mail.UIDFolder
import jakarta.mail.event.MessageCountAdapter
import jakarta.mail.event.MessageCountEvent
+import jakarta.mail.internet.ContentType
import jakarta.mail.internet.InternetAddress
+import jakarta.mail.internet.MimeUtility
import java.util.Properties
import javax.inject.Inject
import javax.inject.Singleton
@@ -39,10 +41,26 @@ data class FetchedMessage(
val isFlagged: Boolean,
)
-/** A message body extracted from the server. */
+/** A message body extracted from the server, with metadata for any attachment parts. */
data class MessageContent(
val body: String,
val isHtml: Boolean,
+ val attachments: List = emptyList(),
+)
+
+/** Metadata for one attachment part. [partIndex] is its position in attachment-tree order. */
+data class AttachmentPart(
+ val partIndex: Int,
+ val filename: String,
+ val mimeType: String,
+ val sizeBytes: Long,
+)
+
+/** A downloaded attachment's bytes plus the metadata needed to open it. */
+class DownloadedAttachment(
+ val filename: String,
+ val mimeType: String,
+ val bytes: ByteArray,
)
/** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */
@@ -104,7 +122,8 @@ class ImapClient @Inject constructor() {
try {
val message = (inbox as UIDFolder).getMessageByUID(uid.toLong())
?: error("Message $uid not found")
- val content = extractBody(message) ?: MessageContent("", isHtml = false)
+ val content = (extractBody(message) ?: MessageContent("", isHtml = false))
+ .copy(attachments = collectAttachments(message))
message.setFlag(Flags.Flag.SEEN, true)
content
} finally {
@@ -113,6 +132,30 @@ class ImapClient @Inject constructor() {
}
}
+ /** Downloads the bytes of one attachment part (identified by its [partIndex]). */
+ suspend fun fetchAttachment(params: ImapConnectionParams, uid: String, partIndex: Int): DownloadedAttachment =
+ withContext(Dispatchers.IO) {
+ withStore(params) { store ->
+ val inbox = store.getFolder("INBOX")
+ inbox.open(Folder.READ_ONLY)
+ try {
+ val message = (inbox as UIDFolder).getMessageByUID(uid.toLong())
+ ?: error("Message $uid not found")
+ val parts = mutableListOf()
+ collectAttachmentParts(message, parts)
+ val part = parts.getOrNull(partIndex) ?: error("Attachment $partIndex not found")
+ val bytes = part.inputStream.use { it.readBytes() }
+ DownloadedAttachment(
+ filename = attachmentName(part) ?: "attachment",
+ mimeType = baseType(part),
+ bytes = bytes,
+ )
+ } finally {
+ runCatching { inbox.close(false) }
+ }
+ }
+ }
+
suspend fun setFlag(params: ImapConnectionParams, uid: String, flag: Flags.Flag, value: Boolean) =
withContext(Dispatchers.IO) {
withStore(params) { store ->
@@ -216,6 +259,39 @@ class ImapClient @Inject constructor() {
return null
}
+ /** Walks the MIME tree and returns attachment metadata in a stable, depth-first order. */
+ private fun collectAttachments(message: Part): List {
+ val parts = mutableListOf()
+ collectAttachmentParts(message, parts)
+ return parts.mapIndexed { index, part ->
+ AttachmentPart(
+ partIndex = index,
+ filename = attachmentName(part) ?: "attachment",
+ mimeType = baseType(part),
+ sizeBytes = part.size.toLong().coerceAtLeast(0L),
+ )
+ }
+ }
+
+ private fun collectAttachmentParts(part: Part, into: MutableList) {
+ when {
+ part.isMimeType("multipart/*") -> {
+ val multipart = part.content as? Multipart ?: return
+ for (i in 0 until multipart.count) collectAttachmentParts(multipart.getBodyPart(i), into)
+ }
+ isAttachment(part) -> into.add(part)
+ }
+ }
+
+ private fun isAttachment(part: Part): Boolean =
+ Part.ATTACHMENT.equals(part.disposition, ignoreCase = true) || !part.fileName.isNullOrBlank()
+
+ private fun attachmentName(part: Part): String? =
+ part.fileName?.let { runCatching { MimeUtility.decodeText(it) }.getOrDefault(it) }
+
+ private fun baseType(part: Part): String =
+ runCatching { ContentType(part.contentType).baseType }.getOrDefault("application/octet-stream")
+
private inline fun withStore(params: ImapConnectionParams, block: (Store) -> T): T {
val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap"
val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol)
diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt
index 7b8da9b..6c0c9b0 100644
--- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reader
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -28,20 +32,29 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
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.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import java.io.File
import org.libremail.R
+import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Message
@OptIn(ExperimentalMaterial3Api::class)
@@ -52,12 +65,31 @@ fun ReaderScreen(
viewModel: ReaderViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val snackbarHostState = remember { SnackbarHostState() }
+ val noAppMessage = stringResource(R.string.attachment_no_app)
+ val downloadFailedTemplate = stringResource(R.string.attachment_download_failed)
LaunchedEffect(state.deleted) {
if (state.deleted) onBack()
}
+ LaunchedEffect(Unit) {
+ viewModel.events.collect { event ->
+ when (event) {
+ is ReaderEvent.OpenFile ->
+ if (!openAttachment(context, event.file, event.mimeType)) {
+ snackbarHostState.showSnackbar(noAppMessage)
+ }
+
+ is ReaderEvent.DownloadFailed ->
+ snackbarHostState.showSnackbar(downloadFailedTemplate.format(event.name))
+ }
+ }
+ }
+
Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.title_reader)) },
@@ -99,6 +131,9 @@ fun ReaderScreen(
message != null -> MessageBody(
message = message,
+ attachments = state.attachments,
+ downloading = state.downloading,
+ onDownloadAttachment = viewModel::downloadAttachment,
loadRemoteImages = state.loadRemoteImages,
onLoadRemoteImages = viewModel::loadRemoteImages,
contentPadding = padding,
@@ -114,6 +149,9 @@ fun ReaderScreen(
@Composable
private fun MessageBody(
message: Message,
+ attachments: List,
+ downloading: Set,
+ onDownloadAttachment: (Attachment) -> Unit,
loadRemoteImages: Boolean,
onLoadRemoteImages: () -> Unit,
contentPadding: PaddingValues,
@@ -121,6 +159,10 @@ private fun MessageBody(
Column(Modifier.fillMaxSize().padding(contentPadding)) {
Header(message)
HorizontalDivider()
+ if (attachments.isNotEmpty()) {
+ Attachments(attachments, downloading, onDownloadAttachment)
+ HorizontalDivider()
+ }
if (message.isHtml && !loadRemoteImages) {
RemoteImagesBanner(onLoadRemoteImages)
}
@@ -149,6 +191,78 @@ private fun MessageBody(
}
}
+@Composable
+private fun Attachments(
+ attachments: List,
+ downloading: Set,
+ onDownload: (Attachment) -> Unit,
+) {
+ Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
+ Text(
+ stringResource(R.string.attachments_title),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(8.dp))
+ attachments.forEach { attachment ->
+ AttachmentRow(
+ attachment = attachment,
+ downloading = attachment.partIndex in downloading,
+ onClick = { onDownload(attachment) },
+ )
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+}
+
+@Composable
+private fun AttachmentRow(attachment: Attachment, downloading: Boolean, onClick: () -> Unit) {
+ Surface(
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = !downloading, onClick = onClick),
+ ) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(MaterialTheme.shapes.small)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (downloading) {
+ CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
+ } else {
+ Text(
+ text = fileExtension(attachment.filename),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+ }
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ attachment.filename,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ val size = formatSize(attachment.sizeBytes)
+ if (size.isNotEmpty()) {
+ Text(
+ size,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+}
+
@Composable
private fun Header(message: Message) {
Column(Modifier.padding(16.dp)) {
@@ -201,3 +315,28 @@ private fun RemoteImagesBanner(onLoadRemoteImages: () -> Unit) {
}
}
}
+
+/** Launches a viewer for the downloaded file via a FileProvider URI. Returns false if no app handles it. */
+private fun openAttachment(context: Context, file: File, mimeType: String): Boolean {
+ val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file)
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, mimeType.ifBlank { "*/*" })
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ return try {
+ context.startActivity(intent)
+ true
+ } catch (_: ActivityNotFoundException) {
+ false
+ }
+}
+
+private fun fileExtension(filename: String): String =
+ filename.substringAfterLast('.', "").uppercase().take(4).ifBlank { "FILE" }
+
+private fun formatSize(bytes: Long): String = when {
+ bytes <= 0 -> ""
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> "%.0f KB".format(bytes / 1024.0)
+ else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt
index dcc3545..313dcc3 100644
--- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt
+++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt
@@ -5,12 +5,16 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.File
import javax.inject.Inject
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Message
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
@@ -18,11 +22,19 @@ import org.libremail.ui.navigation.Routes
data class ReaderUiState(
val loading: Boolean = true,
val message: Message? = null,
+ val attachments: List = emptyList(),
+ val downloading: Set = emptySet(),
val loadRemoteImages: Boolean = false,
val deleted: Boolean = false,
val error: String? = null,
)
+/** One-shot effects the reader screen acts on (launching a viewer, showing a message). */
+sealed interface ReaderEvent {
+ data class OpenFile(val file: File, val mimeType: String, val name: String) : ReaderEvent
+ data class DownloadFailed(val name: String) : ReaderEvent
+}
+
@HiltViewModel
class ReaderViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
@@ -34,6 +46,9 @@ class ReaderViewModel @Inject constructor(
private val _state = MutableStateFlow(ReaderUiState())
val state: StateFlow = _state.asStateFlow()
+ private val _events = Channel(Channel.BUFFERED)
+ val events = _events.receiveAsFlow()
+
init {
viewModelScope.launch {
repository.openMessage(messageId).fold(
@@ -41,6 +56,25 @@ class ReaderViewModel @Inject constructor(
onFailure = { e -> _state.update { it.copy(loading = false, error = e.message ?: "Could not load message") } },
)
}
+ viewModelScope.launch {
+ repository.observeAttachments(messageId).collect { attachments ->
+ _state.update { it.copy(attachments = attachments) }
+ }
+ }
+ }
+
+ fun downloadAttachment(attachment: Attachment) {
+ if (attachment.partIndex in _state.value.downloading) return
+ _state.update { it.copy(downloading = it.downloading + attachment.partIndex) }
+ viewModelScope.launch {
+ repository.downloadAttachment(attachment.messageId, attachment.partIndex).fold(
+ onSuccess = { file ->
+ _events.send(ReaderEvent.OpenFile(file, attachment.mimeType, attachment.filename))
+ },
+ onFailure = { _events.send(ReaderEvent.DownloadFailed(attachment.filename)) },
+ )
+ _state.update { it.copy(downloading = it.downloading - attachment.partIndex) }
+ }
}
fun toggleStar() {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 63b68b3..6e65313 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -38,6 +38,9 @@
Reply
+ Attachments
+ Couldn\'t download %1$s
+ No app can open this file
Sign in with Google
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..446244d
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt
index 433f720..4622abf 100644
--- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt
+++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt
@@ -16,8 +16,10 @@ class MailRepositoryImplTest {
private val messageDao = mockk()
private val repository = MailRepositoryImpl(
+ context = mockk(),
messageDao = messageDao,
accountDao = mockk(),
+ attachmentDao = mockk(),
imapClient = mockk(),
smtpSender = mockk(),
connectionFactory = mockk(),