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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user