Add attachments: download and open from the reader

- Parse attachment metadata while fetching a message body (ImapClient walks the MIME
  tree, collecting parts with a filename or attachment disposition in a stable order);
  a new fetchAttachment(uid, partIndex) downloads one part's bytes on demand.
- Persist attachment metadata in a new Room `attachments` table (entity + DAO +
  MIGRATION_3_4, DB v4), populated when a message is opened so it survives re-opens.
- Reader shows an Attachments section (filename, size, type badge); tapping downloads
  the part to a cache file and opens it in a system viewer via a FileProvider content
  URI (ACTION_VIEW), with a snackbar when the download fails or no app can open it.
- assemble/test/lint green; verified on the Android 17 emulator against GreenMail —
  a PNG-attachment message rendered the attachment, and tapping it fetched the exact
  1049-byte file into the cache and dispatched an image/png VIEW intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 23:25:13 -05:00
co-authored by Claude Opus 4.8
parent 2acff395a1
commit 6d3360fdb9
17 changed files with 415 additions and 8 deletions
+3 -3
View File
@@ -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)
+11
View File
@@ -47,5 +47,16 @@
android:name=".push.IdleService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Shares downloaded attachments with viewer apps via a content:// URI. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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
}
@@ -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)
@@ -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`)",
)
}
}
@@ -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<List<AttachmentEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(attachments: List<AttachmentEntity>)
@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<AttachmentEntity>) {
deleteForMessage(messageId)
insert(attachments)
}
}
@@ -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,
)
@@ -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<List<Attachment>> =
attachmentDao.observeForMessage(messageId).map { rows -> rows.map { it.toDomain() } }
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> = 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<Unit> = 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()
@@ -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()
}
@@ -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,
)
@@ -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<Message>
/** Cached attachment metadata for a message, populated when the message is opened. */
fun observeAttachments(messageId: String): Flow<List<Attachment>>
/** Downloads an attachment's bytes to a local cache file and returns it. */
suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File>
suspend fun setStarred(id: String, starred: Boolean): Result<Unit>
suspend fun deleteMessage(id: String): Result<Unit>
@@ -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<AttachmentPart> = 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<Part>()
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<AttachmentPart> {
val parts = mutableListOf<Part>()
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<Part>) {
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 <T> 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)
@@ -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<Attachment>,
downloading: Set<Int>,
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<Attachment>,
downloading: Set<Int>,
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))
}
@@ -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<Attachment> = emptyList(),
val downloading: Set<Int> = 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<ReaderUiState> = _state.asStateFlow()
private val _events = Channel<ReaderEvent>(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() {
+3
View File
@@ -38,6 +38,9 @@
<!-- Reader -->
<string name="reader_reply">Reply</string>
<string name="attachments_title">Attachments</string>
<string name="attachment_download_failed">Couldn\'t download %1$s</string>
<string name="attachment_no_app">No app can open this file</string>
<!-- Account setup -->
<string name="account_setup_gmail">Sign in with Google</string>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<paths>
<cache-path name="attachments" path="attachments/" />
</paths>
@@ -16,8 +16,10 @@ class MailRepositoryImplTest {
private val messageDao = mockk<MessageDao>()
private val repository = MailRepositoryImpl(
context = mockk(),
messageDao = messageDao,
accountDao = mockk(),
attachmentDao = mockk(),
imapClient = mockk(),
smtpSender = mockk(),
connectionFactory = mockk(),