Add IMAP receive: background sync of inbox into Room
Increment 3 — receive. - ImapClient.fetchRecentInbox pulls recent INBOX headers (ENVELOPE/FLAGS/UID) over IMAP (password or XOAUTH2) into FetchedMessage. - MailSyncer orchestrates per-account fetch -> Room (replace-per-account), refreshing and re-persisting the Gmail OAuth token when needed. - WorkManager background sync via a @HiltWorker (periodic 15-min + an expedited one-shot after adding an account); Application supplies the HiltWorkerFactory and the default WorkManager initializer is removed. - Mailbox renders real cached mail with pull-to-refresh and proper empty states (welcome/add-account vs no-messages); the sample-data crutch is removed. - Shared entity mappers; MessageDao.replaceAccountMessages transaction. - Tests: GreenMail-backed fetchRecentInbox unit test (deliver via SMTP, read via IMAP, newest-first). Instrumented Keystore + Angus-provider tests stay green on the Android 17 emulator, where the SyncWorker also runs to SUCCESS. - Add error_prone_annotations to the compile classpath (Hilt/Dagger codegen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
@@ -23,5 +24,18 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- WorkManager is initialized on-demand via Configuration.Provider, so remove the
|
||||
default automatic initializer. -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -13,6 +13,9 @@ interface AccountDao {
|
||||
@Query("SELECT * FROM accounts ORDER BY email")
|
||||
fun observeAll(): Flow<List<AccountEntity>>
|
||||
|
||||
@Query("SELECT * FROM accounts ORDER BY email")
|
||||
suspend fun getAll(): List<AccountEntity>
|
||||
|
||||
@Query("SELECT * FROM accounts WHERE id = :id LIMIT 1")
|
||||
suspend fun getById(id: String): AccountEntity?
|
||||
|
||||
|
||||
@@ -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<List<MessageEntity>>
|
||||
abstract fun observeAll(): Flow<List<MessageEntity>>
|
||||
|
||||
@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<MessageEntity>)
|
||||
abstract suspend fun upsertAll(messages: List<MessageEntity>)
|
||||
|
||||
@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<MessageEntity>) {
|
||||
deleteByAccount(accountId)
|
||||
upsertAll(messages)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<Account>> =
|
||||
@@ -31,9 +31,10 @@ class AccountRepositoryImpl @Inject constructor(
|
||||
runCatching { imapClient.listFolders(params) }
|
||||
|
||||
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> = 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<List<String>> = 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)
|
||||
|
||||
@@ -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<List<Message>> =
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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<Message> = 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 }
|
||||
}
|
||||
@@ -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<Int> {
|
||||
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<Int> = 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
|
||||
}
|
||||
}
|
||||
@@ -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<SyncWorker>(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<SyncWorker>()
|
||||
.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"
|
||||
}
|
||||
}
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
@@ -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<String> = 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<FetchedMessage> =
|
||||
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 <T> 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() }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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<List<Message>> =
|
||||
repository.observeMessages()
|
||||
mailRepository.observeMessages()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val hasAccounts: StateFlow<Boolean> =
|
||||
accountRepository.observeAccounts()
|
||||
.map { it.isNotEmpty() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
<!-- Mailbox -->
|
||||
<string name="mailbox_empty">No messages yet</string>
|
||||
<string name="mailbox_empty_subtitle">Add an account to start syncing your mail.</string>
|
||||
<string name="mailbox_pull_to_refresh">Pull down to refresh</string>
|
||||
<string name="mailbox_welcome_title">Welcome to LibreMail</string>
|
||||
<string name="mailbox_welcome_subtitle">Add an account to start reading your mail.</string>
|
||||
|
||||
<!-- Compose -->
|
||||
<string name="compose_to">To</string>
|
||||
|
||||
@@ -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<MessageDao>()
|
||||
|
||||
@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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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<Exception> {
|
||||
client.listFolders(params(secret = "wrong-password"))
|
||||
}
|
||||
assertFailsWith<Exception> { 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user