diff --git a/README.md b/README.md index 9944cd5..2f7cc02 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ A free and open-source email client for Android, built with Kotlin, Jetpack Compose and Material 3 (Material You). LibreMail aims for a friendly default experience with power-user features tucked under an **Advanced Settings** group. -> Status: **in development.** The app builds and runs with a Material You shell, -> and **account setup is implemented** — Gmail OAuth (AppAuth, PKCE) and generic -> IMAP/SMTP, each with a live "test connection" that logs in and lists folders, -> and credentials encrypted via the Android Keystore. IMAP sync and sending land -> in upcoming increments. +> Status: **in development.** Material You shell; **account setup** (Gmail OAuth via +> AppAuth/PKCE and generic IMAP/SMTP, with a live connection test and Keystore- +> encrypted credentials); and **IMAP receive** — background sync (WorkManager) that +> fetches your inbox headers into a local Room cache, shown with pull-to-refresh. +> Reading message bodies and sending land in upcoming increments. ## Features (target MVP) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd054a7..d56e61d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -99,7 +99,12 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) + compileOnly(libs.error.prone.annotations) implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) + + implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9a46f81..7c4d4cb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ - + @@ -23,5 +24,18 @@ + + + + + diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt index 6f12a9b..ce5597c 100644 --- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt +++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt @@ -2,7 +2,26 @@ package org.libremail import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject +import org.libremail.data.sync.SyncScheduler @HiltAndroidApp -class LibreMailApplication : Application() +class LibreMailApplication : Application(), Configuration.Provider { + + @Inject lateinit var workerFactory: HiltWorkerFactory + + @Inject lateinit var syncScheduler: SyncScheduler + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + + override fun onCreate() { + super.onCreate() + syncScheduler.schedulePeriodicSync() + } +} diff --git a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt index 68de8f8..153466c 100644 --- a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt +++ b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt @@ -79,6 +79,26 @@ class GmailAuthManager @Inject constructor( } } + /** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */ + suspend fun freshAccessToken(authStateJson: String): FreshToken { + val authState = AuthState.jsonDeserialize(authStateJson) + val service = AuthorizationService(context) + try { + val accessToken = suspendCancellableCoroutine { continuation -> + authState.performActionWithFreshTokens(service) { token, _, error -> + if (token != null) { + continuation.resume(token) + } else { + continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed")) + } + } + } + return FreshToken(accessToken = accessToken, authStateJson = authState.jsonSerializeString()) + } finally { + service.dispose() + } + } + private fun emailFromIdToken(idToken: String?): String? { if (idToken.isNullOrBlank()) return null return runCatching { diff --git a/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt b/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt index 961e777..ca968a0 100644 --- a/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt +++ b/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt @@ -8,3 +8,9 @@ data class OAuthResult( /** Serialized [net.openid.appauth.AuthState], stored encrypted for later token refresh. */ val authStateJson: String, ) + +/** A freshly-refreshed access token plus the (possibly updated) AuthState to persist. */ +data class FreshToken( + val accessToken: String, + val authStateJson: String, +) diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt new file mode 100644 index 0000000..54c7466 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import org.libremail.data.local.entity.AccountEntity +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.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.mail.FetchedMessage + +internal fun AccountEntity.toDomain(): Account = Account( + id = id, + email = email, + displayName = displayName, + authType = runCatching { AuthType.valueOf(authType) }.getOrDefault(AuthType.PASSWORD_IMAP), + imap = ServerConfig(imap.host, imap.port, imap.security.toMailSecurity()), + smtp = ServerConfig(smtp.host, smtp.port, smtp.security.toMailSecurity()), +) + +internal fun Account.toEntity(): AccountEntity = AccountEntity( + id = id, + email = email, + displayName = displayName, + authType = authType.name, + imap = ServerConfigEmbedded(imap.host, imap.port, imap.security.name), + smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name), +) + +internal fun Account.toImapParams(secret: String, useXoauth2: Boolean): ImapConnectionParams = + ImapConnectionParams( + host = imap.host, + port = imap.port, + security = imap.security, + username = email, + secret = secret, + useXoauth2 = useXoauth2, + ) + +internal fun MessageEntity.toDomain(): Message = Message( + id = id, + accountId = accountId, + sender = sender, + senderEmail = senderEmail, + subject = subject, + snippet = snippet, + body = body, + timestampMillis = timestampMillis, + isRead = isRead, + isStarred = isStarred, +) + +internal fun FetchedMessage.toEntity(accountId: String): MessageEntity = MessageEntity( + id = "$accountId:$uid", + accountId = accountId, + sender = sender, + senderEmail = senderEmail, + subject = subject, + snippet = "", + body = "", + timestampMillis = timestampMillis, + isRead = isRead, + isStarred = isFlagged, +) + +private fun String.toMailSecurity(): MailSecurity = + runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS) diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt index 37ba317..45ff38d 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt @@ -13,6 +13,9 @@ interface AccountDao { @Query("SELECT * FROM accounts ORDER BY email") fun observeAll(): Flow> + @Query("SELECT * FROM accounts ORDER BY email") + suspend fun getAll(): List + @Query("SELECT * FROM accounts WHERE id = :id LIMIT 1") suspend fun getById(id: String): AccountEntity? diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt index eca8f49..6ced8d7 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt @@ -5,17 +5,28 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import kotlinx.coroutines.flow.Flow import org.libremail.data.local.entity.MessageEntity @Dao -interface MessageDao { +abstract class MessageDao { @Query("SELECT * FROM messages ORDER BY timestampMillis DESC") - fun observeAll(): Flow> + abstract fun observeAll(): Flow> @Query("SELECT * FROM messages WHERE id = :id LIMIT 1") - suspend fun getById(id: String): MessageEntity? + abstract suspend fun getById(id: String): MessageEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertAll(messages: List) + abstract suspend fun upsertAll(messages: List) + + @Query("DELETE FROM messages WHERE accountId = :accountId") + abstract suspend fun deleteByAccount(accountId: String) + + /** Replace an account's cached messages with a freshly fetched set, atomically. */ + @Transaction + open suspend fun replaceAccountMessages(accountId: String, messages: List) { + deleteByAccount(accountId) + upsertAll(messages) + } } diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt index e45dcb2..7fc087e 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt @@ -6,14 +6,13 @@ 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.entity.AccountEntity -import org.libremail.data.local.entity.ServerConfigEmbedded +import org.libremail.data.local.toDomain +import org.libremail.data.local.toEntity +import org.libremail.data.local.toImapParams import org.libremail.data.security.CredentialStore +import org.libremail.data.sync.SyncScheduler import org.libremail.domain.model.Account -import org.libremail.domain.model.AuthType import org.libremail.domain.model.ImapConnectionParams -import org.libremail.domain.model.MailSecurity -import org.libremail.domain.model.ServerConfig import org.libremail.domain.repository.AccountRepository import org.libremail.mail.ImapClient @@ -22,6 +21,7 @@ class AccountRepositoryImpl @Inject constructor( private val accountDao: AccountDao, private val credentialStore: CredentialStore, private val imapClient: ImapClient, + private val syncScheduler: SyncScheduler, ) : AccountRepository { override fun observeAccounts(): Flow> = @@ -31,9 +31,10 @@ class AccountRepositoryImpl @Inject constructor( runCatching { imapClient.listFolders(params) } override suspend fun addImapAccount(account: Account, password: String): Result> = runCatching { - val folders = imapClient.listFolders(account.imapParams(secret = password, useXoauth2 = false)) + val folders = imapClient.listFolders(account.toImapParams(secret = password, useXoauth2 = false)) accountDao.upsert(account.toEntity()) credentialStore.saveSecret(account.id, password) + syncScheduler.syncNow() folders } @@ -43,9 +44,10 @@ class AccountRepositoryImpl @Inject constructor( authStateJson: String, ): Result> = runCatching { val account = Account.gmail(email) - val folders = imapClient.listFolders(account.imapParams(secret = accessToken, useXoauth2 = true)) + val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true)) accountDao.upsert(account.toEntity()) credentialStore.saveSecret(account.id, authStateJson) + syncScheduler.syncNow() folders } @@ -54,33 +56,3 @@ class AccountRepositoryImpl @Inject constructor( credentialStore.delete(id) } } - -private fun Account.imapParams(secret: String, useXoauth2: Boolean) = ImapConnectionParams( - host = imap.host, - port = imap.port, - security = imap.security, - username = email, - secret = secret, - useXoauth2 = useXoauth2, -) - -private fun Account.toEntity() = AccountEntity( - id = id, - email = email, - displayName = displayName, - authType = authType.name, - imap = ServerConfigEmbedded(imap.host, imap.port, imap.security.name), - smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name), -) - -private fun AccountEntity.toDomain() = Account( - id = id, - email = email, - displayName = displayName, - authType = runCatching { AuthType.valueOf(authType) }.getOrDefault(AuthType.PASSWORD_IMAP), - imap = ServerConfig(imap.host, imap.port, imap.security.toSecurity()), - smtp = ServerConfig(smtp.host, smtp.port, smtp.security.toSecurity()), -) - -private fun String.toSecurity() = - runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS) 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 673a766..46ba186 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -6,8 +6,7 @@ import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.libremail.data.local.dao.MessageDao -import org.libremail.data.local.entity.MessageEntity -import org.libremail.data.sample.SampleData +import org.libremail.data.local.toDomain import org.libremail.domain.model.Message import org.libremail.domain.repository.MailRepository @@ -16,26 +15,9 @@ class MailRepositoryImpl @Inject constructor( private val messageDao: MessageDao, ) : MailRepository { - // Room is the single source of truth. Until sync lands the cache is empty, so we fall - // back to bundled sample data to keep the UI populated. override fun observeMessages(): Flow> = - messageDao.observeAll().map { cached -> - if (cached.isEmpty()) SampleData.messages else cached.map { it.toDomain() } - } + messageDao.observeAll().map { rows -> rows.map { it.toDomain() } } override suspend fun getMessage(id: String): Message? = - messageDao.getById(id)?.toDomain() ?: SampleData.byId(id) + messageDao.getById(id)?.toDomain() } - -private fun MessageEntity.toDomain(): Message = Message( - id = id, - accountId = accountId, - sender = sender, - senderEmail = senderEmail, - subject = subject, - snippet = snippet, - body = body, - timestampMillis = timestampMillis, - isRead = isRead, - isStarred = isStarred, -) diff --git a/app/src/main/kotlin/org/libremail/data/sample/SampleData.kt b/app/src/main/kotlin/org/libremail/data/sample/SampleData.kt deleted file mode 100644 index 4fa11a2..0000000 --- a/app/src/main/kotlin/org/libremail/data/sample/SampleData.kt +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package org.libremail.data.sample - -import org.libremail.domain.model.Message - -/** - * Placeholder content shown until real IMAP sync lands (next increment), so the - * mailbox and reader are populated when the local cache is still empty. - */ -object SampleData { - private const val MINUTE = 60_000L - private const val HOUR = 60 * MINUTE - private val now = System.currentTimeMillis() - - val messages: List = listOf( - Message( - id = "sample-1", - accountId = "sample", - sender = "LibreMail", - senderEmail = "hello@libremail.org", - subject = "Welcome to LibreMail", - snippet = "Thanks for trying LibreMail — a free, open-source email client.", - body = "Thanks for trying LibreMail!\n\n" + - "This is placeholder content. Account sign-in, IMAP sync and sending " + - "arrive in upcoming increments. The screen you are looking at is wired " + - "to the same ViewModel → Repository → Room pipeline the real data will use.", - timestampMillis = now - 5 * MINUTE, - isRead = false, - isStarred = true, - ), - Message( - id = "sample-2", - accountId = "sample", - sender = "Material You", - senderEmail = "design@android.example", - subject = "Your theme follows the wallpaper", - snippet = "On Android 12+ the colors you see are derived from your wallpaper.", - body = "LibreMail uses Material 3 dynamic color. On Android 12 and newer, the " + - "accent colors are generated from your wallpaper. On older versions it falls " + - "back to the LibreMail brand palette.", - timestampMillis = now - 3 * HOUR, - isRead = false, - isStarred = false, - ), - Message( - id = "sample-3", - accountId = "sample", - sender = "Privacy", - senderEmail = "privacy@libremail.org", - subject = "Remote images are blocked by default", - snippet = "We block remote content to protect you from tracking pixels.", - body = "By default, remote images in HTML email are not loaded, which prevents " + - "senders from tracking when you open a message. You can change this under " + - "Settings → Advanced Settings.", - timestampMillis = now - 26 * HOUR, - isRead = true, - isStarred = false, - ), - ) - - fun byId(id: String): Message? = messages.firstOrNull { it.id == id } -} diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt new file mode 100644 index 0000000..96c2b50 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import javax.inject.Inject +import javax.inject.Singleton +import org.libremail.auth.GmailAuthManager +import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.MessageDao +import org.libremail.data.local.toDomain +import org.libremail.data.local.toEntity +import org.libremail.data.local.toImapParams +import org.libremail.data.security.CredentialStore +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.mail.ImapClient + +/** Fetches each account's recent INBOX headers and writes them into Room (the source of truth). */ +@Singleton +class MailSyncer @Inject constructor( + private val accountDao: AccountDao, + private val messageDao: MessageDao, + private val credentialStore: CredentialStore, + private val imapClient: ImapClient, + private val gmailAuthManager: GmailAuthManager, +) { + /** Syncs every account. Succeeds if at least one account synced (or there are none). */ + suspend fun syncAll(): Result { + val accounts = accountDao.getAll() + if (accounts.isEmpty()) return Result.success(0) + + var total = 0 + var firstError: Throwable? = null + var anySuccess = false + for (entity in accounts) { + syncAccount(entity.toDomain()).fold( + onSuccess = { total += it; anySuccess = true }, + onFailure = { error -> if (firstError == null) firstError = error }, + ) + } + return if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError!!) + } + + private suspend fun syncAccount(account: Account): Result = runCatching { + val secret = resolveSecret(account) + val fetched = imapClient.fetchRecentInbox( + account.toImapParams(secret = secret, useXoauth2 = account.authType == AuthType.OAUTH_GMAIL), + INBOX_LIMIT, + ) + messageDao.replaceAccountMessages(account.id, fetched.map { it.toEntity(account.id) }) + fetched.size + } + + /** Returns a usable IMAP secret, refreshing and re-persisting the OAuth token when needed. */ + private suspend fun resolveSecret(account: Account): String { + val stored = credentialStore.loadSecret(account.id) + ?: error("No stored credentials for ${account.email}") + return when (account.authType) { + AuthType.PASSWORD_IMAP -> stored + AuthType.OAUTH_GMAIL -> { + val fresh = gmailAuthManager.freshAccessToken(stored) + if (fresh.authStateJson != stored) { + credentialStore.saveSecret(account.id, fresh.authStateJson) + } + fresh.accessToken + } + } + } + + private companion object { + const val INBOX_LIMIT = 50 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt b/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt new file mode 100644 index 0000000..c6706bd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** Schedules background mail sync via WorkManager. */ +@Singleton +class SyncScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val workManager get() = WorkManager.getInstance(context) + + private val networkConstraint = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + /** Periodic background sync (WorkManager's 15-minute floor). */ + fun schedulePeriodicSync() { + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(networkConstraint) + .build() + workManager.enqueueUniquePeriodicWork(PERIODIC_WORK, ExistingPeriodicWorkPolicy.KEEP, request) + } + + /** One-shot sync, e.g. right after an account is added. */ + fun syncNow() { + val request = OneTimeWorkRequestBuilder() + .setConstraints(networkConstraint) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + workManager.enqueueUniqueWork(ONESHOT_WORK, ExistingWorkPolicy.REPLACE, request) + } + + private companion object { + const val PERIODIC_WORK = "libremail_periodic_sync" + const val ONESHOT_WORK = "libremail_oneshot_sync" + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt new file mode 100644 index 0000000..75929dd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val mailSyncer: MailSyncer, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = + mailSyncer.syncAll().fold( + onSuccess = { Result.success() }, + onFailure = { Result.retry() }, + ) +} diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 03e85bb..20d3b43 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -1,7 +1,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.mail +import jakarta.mail.FetchProfile +import jakarta.mail.Flags +import jakarta.mail.Folder import jakarta.mail.Session +import jakarta.mail.Store +import jakarta.mail.UIDFolder +import jakarta.mail.internet.InternetAddress import java.util.Properties import javax.inject.Inject import javax.inject.Singleton @@ -10,12 +16,68 @@ import kotlinx.coroutines.withContext import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity +/** A message header fetched from the server (no body — that arrives with the reader). */ +data class FetchedMessage( + val uid: String, + val sender: String, + val senderEmail: String, + val subject: String, + val timestampMillis: Long, + val isRead: Boolean, + val isFlagged: Boolean, +) + /** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */ @Singleton class ImapClient @Inject constructor() { /** Connects and returns the account's folder names. Throws on failure. */ suspend fun listFolders(params: ImapConnectionParams): List = withContext(Dispatchers.IO) { + withStore(params) { store -> + store.defaultFolder.list("*").map { it.fullName } + } + } + + /** Fetches the most recent [limit] INBOX headers, newest first. */ + suspend fun fetchRecentInbox(params: ImapConnectionParams, limit: Int): List = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_ONLY) + try { + val total = inbox.messageCount + if (total == 0) return@withStore emptyList() + + val messages = inbox.getMessages(maxOf(1, total - limit + 1), total) + inbox.fetch( + messages, + FetchProfile().apply { + add(FetchProfile.Item.ENVELOPE) + add(FetchProfile.Item.FLAGS) + add(UIDFolder.FetchProfileItem.UID) + }, + ) + val uidFolder = inbox as UIDFolder + messages.reversed().map { message -> + val from = message.from?.firstOrNull() as? InternetAddress + FetchedMessage( + uid = uidFolder.getUID(message).toString(), + sender = from?.personal ?: from?.address ?: "(unknown sender)", + senderEmail = from?.address.orEmpty(), + subject = message.subject ?: "(no subject)", + timestampMillis = (message.sentDate ?: message.receivedDate)?.time + ?: System.currentTimeMillis(), + isRead = message.isSet(Flags.Flag.SEEN), + isFlagged = message.isSet(Flags.Flag.FLAGGED), + ) + } + } finally { + runCatching { inbox.close(false) } + } + } + } + + private inline fun withStore(params: ImapConnectionParams, block: (Store) -> T): T { val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap" val props = Properties().apply { put("mail.store.protocol", protocol) @@ -32,11 +94,10 @@ class ImapClient @Inject constructor() { put("mail.$protocol.auth.mechanisms", "XOAUTH2") } } - val store = Session.getInstance(props).getStore(protocol) store.connect(params.host, params.port, params.username, params.secret) - try { - store.defaultFolder.list("*").map { it.fullName } + return try { + block(store) } finally { runCatching { store.close() } } diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 2ff02d0..700f91d 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -45,6 +45,7 @@ fun LibreMailApp() { MailboxScreen( onOpenMessage = { id -> navController.navigate(Routes.reader(id)) }, onCompose = { navController.navigate(Routes.COMPOSE) }, + onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) }, onSelectTab = navController::navigateTab, ) } diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index 03561d4..1498beb 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -21,16 +21,22 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox 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 @@ -42,21 +48,37 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.libremail.R import org.libremail.domain.model.Message -import org.libremail.ui.LibreMailBottomBar -import org.libremail.ui.TopDest @OptIn(ExperimentalMaterial3Api::class) @Composable fun MailboxScreen( onOpenMessage: (String) -> Unit, onCompose: () -> Unit, - onSelectTab: (TopDest) -> Unit, + onAddAccount: () -> Unit, + onSelectTab: (org.libremail.ui.TopDest) -> Unit, viewModel: MailboxViewModel = hiltViewModel(), ) { val messages by viewModel.messages.collectAsStateWithLifecycle() + val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(error) { + error?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeError() + } + } + Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.title_mailbox)) }) }, - bottomBar = { LibreMailBottomBar(current = TopDest.MAILBOX, onSelect = onSelectTab) }, + bottomBar = { + org.libremail.ui.LibreMailBottomBar( + current = org.libremail.ui.TopDest.MAILBOX, + onSelect = onSelectTab, + ) + }, floatingActionButton = { ExtendedFloatingActionButton( onClick = onCompose, @@ -64,14 +86,27 @@ fun MailboxScreen( text = { Text(stringResource(R.string.action_compose)) }, ) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> - if (messages.isEmpty()) { - EmptyState(Modifier.padding(padding)) - } else { - LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { - items(messages, key = { it.id }) { message -> - MessageRow(message = message, onClick = { onOpenMessage(message.id) }) - HorizontalDivider() + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + if (!hasAccounts) { + NoAccountState(onAddAccount = onAddAccount) + } else { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + if (messages.isEmpty()) { + item { NoMessagesState(Modifier.fillParentMaxSize()) } + } else { + items(messages, key = { it.id }) { message -> + MessageRow(message = message, onClick = { onOpenMessage(message.id) }) + HorizontalDivider() + } + } + } } } } @@ -113,13 +148,15 @@ private fun MessageRow(message: Message, onClick: () -> Unit) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - text = message.snippet, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (message.snippet.isNotBlank()) { + Text( + text = message.snippet, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -142,9 +179,37 @@ private fun Avatar(name: String) { } @Composable -private fun EmptyState(modifier: Modifier = Modifier) { +private fun NoAccountState(onAddAccount: () -> Unit) { Column( - modifier = modifier.fillMaxSize().padding(24.dp), + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.Email, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(16.dp)) + Text(stringResource(R.string.mailbox_welcome_title), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.mailbox_welcome_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + Button(onClick = onAddAccount) { + Text(stringResource(R.string.settings_add_account)) + } + } +} + +@Composable +private fun NoMessagesState(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -158,7 +223,7 @@ private fun EmptyState(modifier: Modifier = Modifier) { Text(stringResource(R.string.mailbox_empty), style = MaterialTheme.typography.titleMedium) Spacer(Modifier.height(4.dp)) Text( - stringResource(R.string.mailbox_empty_subtitle), + stringResource(R.string.mailbox_pull_to_refresh), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 8d92ad0..55419d5 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -5,17 +5,50 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.libremail.data.sync.MailSyncer import org.libremail.domain.model.Message +import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository @HiltViewModel class MailboxViewModel @Inject constructor( - repository: MailRepository, + mailRepository: MailRepository, + accountRepository: AccountRepository, + private val mailSyncer: MailSyncer, ) : ViewModel() { + val messages: StateFlow> = - repository.observeMessages() + mailRepository.observeMessages() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val hasAccounts: StateFlow = + accountRepository.observeAccounts() + .map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + fun refresh() { + if (_isRefreshing.value) return + viewModelScope.launch { + _isRefreshing.value = true + mailSyncer.syncAll().onFailure { _error.value = it.message ?: "Sync failed" } + _isRefreshing.value = false + } + } + + fun consumeError() { + _error.value = null + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74c92fd..bcde14f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,7 +19,9 @@ No messages yet - Add an account to start syncing your mail. + Pull down to refresh + Welcome to LibreMail + Add an account to start reading your mail. To 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 8978330..b4a8007 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -11,25 +11,22 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.entity.MessageEntity -import org.libremail.data.sample.SampleData class MailRepositoryImplTest { private val messageDao = mockk() @Test - fun `observeMessages emits sample data when cache is empty`() = runTest { + fun `observeMessages is empty when the cache is empty`() = runTest { every { messageDao.observeAll() } returns flowOf(emptyList()) - val repository = MailRepositoryImpl(messageDao) - - repository.observeMessages().test { - assertEquals(SampleData.messages, awaitItem()) + MailRepositoryImpl(messageDao).observeMessages().test { + assertTrue(awaitItem().isEmpty()) awaitComplete() } } @Test - fun `observeMessages maps cached entities when present`() = runTest { + fun `observeMessages maps cached entities`() = runTest { val entity = MessageEntity( id = "1", accountId = "a", @@ -43,9 +40,7 @@ class MailRepositoryImplTest { isStarred = false, ) every { messageDao.observeAll() } returns flowOf(listOf(entity)) - val repository = MailRepositoryImpl(messageDao) - - repository.observeMessages().test { + MailRepositoryImpl(messageDao).observeMessages().test { val items = awaitItem() assertEquals(1, items.size) assertEquals("Ada", items.first().sender) diff --git a/app/src/test/kotlin/org/libremail/data/sample/SampleDataTest.kt b/app/src/test/kotlin/org/libremail/data/sample/SampleDataTest.kt deleted file mode 100644 index e7075fa..0000000 --- a/app/src/test/kotlin/org/libremail/data/sample/SampleDataTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package org.libremail.data.sample - -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import org.junit.Test - -class SampleDataTest { - - @Test - fun `byId returns a known sample message`() { - val message = SampleData.byId("sample-1") - assertNotNull(message) - assertEquals("sample-1", message.id) - } - - @Test - fun `byId returns null for an unknown id`() { - assertNull(SampleData.byId("does-not-exist")) - } -} diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt index 516e088..0d6bda1 100644 --- a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -2,7 +2,9 @@ package org.libremail.mail import com.icegreen.greenmail.util.GreenMail +import com.icegreen.greenmail.util.GreenMailUtil import com.icegreen.greenmail.util.ServerSetupTest +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest @@ -19,7 +21,7 @@ class ImapClientTest { @Before fun setUp() { - greenMail = GreenMail(ServerSetupTest.IMAP) + greenMail = GreenMail(ServerSetupTest.SMTP_IMAP) greenMail.start() greenMail.setUser("alice@example.org", "secret") } @@ -29,7 +31,7 @@ class ImapClientTest { greenMail.stop() } - private fun params(secret: String) = ImapConnectionParams( + private fun params(secret: String = "secret") = ImapConnectionParams( host = "127.0.0.1", port = greenMail.imap.port, security = MailSecurity.NONE, @@ -40,14 +42,25 @@ class ImapClientTest { @Test fun `listFolders returns INBOX for a valid login`() = runTest { - val folders = client.listFolders(params(secret = "secret")) - assertTrue(folders.any { it.equals("INBOX", ignoreCase = true) }, "folders=$folders") + assertTrue(client.listFolders(params()).any { it.equals("INBOX", ignoreCase = true) }) } @Test fun `listFolders fails for a wrong password`() = runTest { - assertFailsWith { - client.listFolders(params(secret = "wrong-password")) - } + assertFailsWith { client.listFolders(params(secret = "wrong-password")) } + } + + @Test + fun `fetchRecentInbox returns delivered messages newest first`() = runTest { + GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "First subject", "Body one") + GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Second subject", "Body two") + greenMail.waitForIncomingEmail(2) + + val messages = client.fetchRecentInbox(params(), limit = 50) + + assertEquals(2, messages.size) + assertEquals("Second subject", messages.first().subject) + assertEquals(setOf("First subject", "Second subject"), messages.map { it.subject }.toSet()) + assertEquals("bob@example.org", messages.first { it.subject == "First subject" }.senderEmail) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a65d946..4dc4bda 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ espresso = "3.6.1" turbine = "1.2.1" mockk = "1.14.11" greenmail = "2.1.9" +errorprone = "2.50.0" [libraries] # AndroidX core / lifecycle / activity / navigation @@ -44,8 +45,12 @@ androidx-compose-material-icons-core = { group = "androidx.compose.material", na # Hilt (DI) hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +# Pulled onto the compile classpath because Hilt/Dagger generated code references it. +error-prone-annotations = { group = "com.google.errorprone", name = "error_prone_annotations", version.ref = "errorprone" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltNavigationCompose" } # Room (local cache) androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }