From 6d3360fdb95b1e20a9497e91aba195769c676f66 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 26 Jun 2026 23:25:13 -0500 Subject: [PATCH] Add attachments: download and open from the reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 6 +- app/src/main/AndroidManifest.xml | 11 ++ .../libremail/data/local/LibreMailDatabase.kt | 7 +- .../org/libremail/data/local/Mappers.kt | 19 +++ .../org/libremail/data/local/Migrations.kt | 15 ++ .../libremail/data/local/dao/AttachmentDao.kt | 29 ++++ .../data/local/entity/AttachmentEntity.kt | 19 +++ .../data/repository/MailRepositoryImpl.kt | 29 ++++ .../kotlin/org/libremail/di/DatabaseModule.kt | 7 +- .../org/libremail/domain/model/Attachment.kt | 10 ++ .../domain/repository/MailRepository.kt | 8 + .../kotlin/org/libremail/mail/ImapClient.kt | 80 +++++++++- .../org/libremail/ui/reader/ReaderScreen.kt | 139 ++++++++++++++++++ .../libremail/ui/reader/ReaderViewModel.kt | 34 +++++ app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/file_paths.xml | 5 + .../data/repository/MailRepositoryImplTest.kt | 2 + 17 files changed, 415 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt create mode 100644 app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/Attachment.kt create mode 100644 app/src/main/res/xml/file_paths.xml 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(),