Merge main into feat-155-yahoo-app-password

This commit is contained in:
Jason Ross
2026-07-02 20:32:49 -05:00
committed by GitHub
4 changed files with 125 additions and 2 deletions
@@ -9,8 +9,13 @@ import androidx.paging.PagingData
import androidx.paging.map
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.mail.Flags
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.libremail.data.ReplyBuilder
import org.libremail.data.SignatureBlock
import org.libremail.data.Snippet
@@ -65,6 +70,12 @@ class MailRepositoryImpl @Inject constructor(
private val signatureRepository: SignatureRepository,
) : MailRepository {
// Application-lifetime scope for fire-and-forget server pushes that must outlive the caller — e.g.
// openMessage() returning to the reader screen before the SEEN flag reaches the server (#148). Same
// pattern as LibreMailApplication.appScope / IdleService.scope: this class is @Singleton (bound to
// Hilt's SingletonComponent), so the scope's lifetime is the process's, not any one caller's coroutine.
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>> =
messageDao.observeFolderSummaries(accountId, folder).map { rows -> rows.map { it.toDomain() } }
@@ -114,13 +125,40 @@ class MailRepositoryImpl @Inject constructor(
attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) })
messageDao.setRead(id, true)
} else if (!entity.isRead) {
runCatching { imapClient.setFlag(params, entity.folder, uidOf(id), Flags.Flag.SEEN, true) }
// Optimistic, local-only: the reader can render as soon as this returns. The SEEN flag
// still needs to reach the server, but that IMAP round trip (connection + STORE) must not
// sit on this path (#148) — the body/attachments are already fully local, so nothing about
// rendering the screen needs it. Pushed on backgroundScope, which outlives this call.
messageDao.setRead(id, true)
pushSeenFlagInBackground(params, entity.folder, id)
}
}
messageDao.getById(id)?.toDomain() ?: error("Message not found")
}
/**
* Best-effort, fire-and-forget propagation of the SEEN flag to the server, off the message-open
* critical path (#148). Retries a few times with a short backoff, then gives up silently: local state
* is already correct (the caller set it before launching this), so a permanent failure here just means
* the server's copy stays "unread" until something else touches the flag — e.g. the message is opened
* from another client, or a future sync gains upward read-state reconciliation. Today's folder sync
* does NOT do that: it deliberately leaves cached read/star flags alone when refreshing headers from
* the server (see `MessageDao.updateHeaderContent`), so it protects an optimistic local flag from
* being clobbered by stale server state, but it does not re-drive a push that never reached the server
* either. This retry is in-memory only and does not survive process death mid-backoff.
*/
private fun pushSeenFlagInBackground(params: ImapConnectionParams, folder: String, id: String) {
backgroundScope.launch {
var attempt = 0
while (true) {
attempt++
val result = runCatching { imapClient.setFlag(params, folder, uidOf(id), Flags.Flag.SEEN, true) }
if (result.isSuccess || attempt >= SEEN_FLAG_PUSH_MAX_ATTEMPTS) return@launch
delay(SEEN_FLAG_RETRY_BACKOFF_MS * attempt)
}
}
}
override fun observeAttachments(messageId: String): Flow<List<Attachment>> =
attachmentDao.observeForMessage(messageId).map { rows ->
rows.map { it.toDomain() }
@@ -410,5 +448,11 @@ private const val SEARCH_LIMIT = 50
/** Rows per page for the unified inbox (issue #124) — a page is a few screenfuls of message rows. */
private const val MAILBOX_PAGE_SIZE = 40
/** Attempts for the background best-effort SEEN-flag push before giving up silently (issue #148). */
private const val SEEN_FLAG_PUSH_MAX_ATTEMPTS = 3
/** Base backoff between SEEN-flag push retries, scaled by attempt number (2s, then 4s). */
private const val SEEN_FLAG_RETRY_BACKOFF_MS = 2_000L
/** Message id is "<accountId>:<uid>"; the uid is the trailing segment. */
private fun uidOf(id: String): String = id.substringAfterLast(':')
@@ -190,6 +190,13 @@ private fun FormattingToolbar(
underline = true,
onClick = { onToggleStyle(RichStyle.Underline) },
)
FormatButton(
label = "S",
description = stringResource(R.string.format_strikethrough),
active = RichTextEditing.isStyled(content, start, end, RichStyle.Strikethrough),
strikethrough = true,
onClick = { onToggleStyle(RichStyle.Strikethrough) },
)
FormatButton(
label = "•",
description = stringResource(R.string.format_bullet_list),
@@ -226,6 +233,7 @@ private fun FormatButton(
fontWeight: FontWeight? = null,
fontStyle: FontStyle? = null,
underline: Boolean = false,
strikethrough: Boolean = false,
) {
val colors = MaterialTheme.colorScheme
val background = if (active) colors.secondaryContainer else Color.Transparent
@@ -244,7 +252,11 @@ private fun FormatButton(
style = LocalTextStyle.current.copy(
fontWeight = fontWeight,
fontStyle = fontStyle,
textDecoration = if (underline) TextDecoration.Underline else null,
textDecoration = when {
underline -> TextDecoration.Underline
strikethrough -> TextDecoration.LineThrough
else -> null
},
),
)
}
+1
View File
@@ -97,6 +97,7 @@
<string name="format_bold">Bold</string>
<string name="format_italic">Italic</string>
<string name="format_underline">Underline</string>
<string name="format_strikethrough">Strikethrough</string>
<string name="format_bullet_list">Bulleted list</string>
<string name="format_numbered_list">Numbered list</string>
<string name="format_quote">Block quote</string>
@@ -13,8 +13,12 @@ import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import jakarta.mail.Flags
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import org.junit.Test
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
@@ -207,6 +211,62 @@ class MailRepositoryImplTest {
assertEquals("Tom & Jerry say \"hi\"", snippet.captured)
}
/**
* The #148 fix: when the body is already cached and the message is unread, openMessage() must not
* await the SEEN-flag IMAP round trip before returning. Uses `runBlocking` (not `runTest`) and a
* [CompletableDeferred] gate — same idiom as `MailMaintenanceGateTest` — because the behavior under
* test is genuine concurrency between the caller's coroutine and the repository's own background
* scope, not something a virtual-time test dispatcher can observe.
*/
@Test
fun `openMessage returns without awaiting the background SEEN-flag push`() {
// Block body (not `= runBlocking { ... }`): the last statement below returns Boolean
// (CompletableDeferred.complete), and JUnit4 requires @Test methods to return void/Unit.
runBlocking {
val id = "acct:INBOX:40"
coEvery { messageDao.getById(id) } returns
messageEntity(id, "INBOX", bodyFetched = true) // isRead = false
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
coEvery { messageDao.setRead(id, true) } just Runs
val flagPushStarted = CompletableDeferred<Unit>()
val releaseFlagPush = CompletableDeferred<Unit>()
coEvery { imapClient.setFlag(any(), "INBOX", "40", Flags.Flag.SEEN, true) } coAnswers {
flagPushStarted.complete(Unit)
releaseFlagPush.await() // stays "in flight" until this test explicitly releases it
}
val result = repository.openMessage(id)
// openMessage already completed successfully even though the mocked setFlag call above is
// still parked on releaseFlagPush — proof it is not on the awaited path.
assertTrue(result.isSuccess)
coVerify { messageDao.setRead(id, true) } // the optimistic local write happens synchronously
// The push must still actually happen, just off this path — bounded wait, not indefinite.
withTimeout(FLAG_PUSH_AWAIT_TIMEOUT_MS) { flagPushStarted.await() }
releaseFlagPush.complete(Unit) // let the background coroutine finish cleanly before the test ends
}
}
/** Best-effort means retried, not "one attempt and silently give up" (#148 design point 2). */
@Test
fun `a failed SEEN-flag push is retried in the background`() = runBlocking {
val id = "acct:INBOX:41"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX", bodyFetched = true)
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
coEvery { messageDao.setRead(id, true) } just Runs
coEvery { imapClient.setFlag(any(), "INBOX", "41", Flags.Flag.SEEN, true) } throws RuntimeException("boom")
repository.openMessage(id)
// The first attempt fails immediately; observing a second proves this is a retry loop rather than
// a single best-effort attempt that gives up silently after one failure.
coVerify(timeout = FLAG_PUSH_RETRY_VERIFY_TIMEOUT_MS, atLeast = 2) {
imapClient.setFlag(any(), "INBOX", "41", Flags.Flag.SEEN, true)
}
}
@Test
fun `prefetchMessage leaves a plain-text body's literal angle brackets in the snippet`() = runTest {
val cache = Files.createTempDirectory("attach").toFile()
@@ -656,3 +716,9 @@ class MailRepositoryImplTest {
LoadResult.Page(data = rows, prevKey = null, nextKey = null)
}
}
/** Bounded real-time wait for the background SEEN-flag push to have started (see `pushSeenFlagInBackground`). */
private const val FLAG_PUSH_AWAIT_TIMEOUT_MS = 2_000L
/** Bounded real-time wait for a second (retried) SEEN-flag push attempt to land. */
private const val FLAG_PUSH_RETRY_VERIFY_TIMEOUT_MS = 5_000L