fix(ui): reader Reply quotes original + guard one-shot actions against double-tap

#303: the reader's Reply now routes through MailRepository.buildReplyDraft
(quotes the original into a <blockquote>, bakes the signature, prefixes Re:/Fwd:
without double-prefixing) and opens compose on the built draft via
ReaderEvent.OpenCompose — the same high-fidelity path the mailbox uses — instead
of a bare compose prefill with an empty body. Adds Reply-All and Forward via an
app-bar overflow menu.

#304: SignatureEditViewModel.save, ReportReviewViewModel.submit,
AccountSettingsViewModel.removeAccount, and ProblemReportsViewModel.createManualReport
now flip a busy/saving flag synchronously before the first suspension and gate
their buttons, so a rapid double-tap can't create duplicate signatures/reports,
enqueue two uploads, or over-pop the back stack.

Closes #303
Closes #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 02:36:15 -05:00
co-authored by Claude Opus 4.8
parent b64c7f8dda
commit 6245533368
18 changed files with 353 additions and 22 deletions
@@ -11,6 +11,7 @@ import androidx.compose.ui.test.performClick
import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -18,6 +19,7 @@ import org.libremail.R
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Message
import org.libremail.domain.model.ReplyMode
import org.libremail.ui.FakeMailRepository
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
@@ -44,8 +46,17 @@ class ReaderScreenTest {
private fun attachment(partIndex: Int, filename: String) =
Attachment(messageId, partIndex, filename, "application/pdf", 1_000L)
/** Renders [ReaderScreen] for the fixed [message] with the given [attachments] and awaits load. */
private fun renderReader(attachments: List<Attachment>, downloadedParts: Set<Int> = emptySet()) {
/** The draft id the reader last asked to open compose on (via OpenCompose), or null. */
private var openedDraftId: String? = null
/**
* Renders [ReaderScreen] for the fixed [message] with the given [attachments], awaits load, and
* returns the backing [FakeMailRepository] so a test can assert which reply drafts were built.
*/
private fun renderReader(
attachments: List<Attachment> = emptyList(),
downloadedParts: Set<Int> = emptySet(),
): FakeMailRepository {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val repo = FakeMailRepository(
messages = listOf(message),
@@ -59,12 +70,14 @@ class ReaderScreenTest {
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ReaderScreen(onBack = {}, onReply = { _, _, _ -> }, viewModel = viewModel)
ReaderScreen(onBack = {}, onOpenCompose = { openedDraftId = it }, viewModel = viewModel)
}
}
// The Reply action appears once the message loads, regardless of whether it has attachments.
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(attachments.first().filename).fetchSemanticsNodes().isNotEmpty()
composeTestRule.onAllNodesWithText(string(R.string.reader_reply)).fetchSemanticsNodes().isNotEmpty()
}
return repo
}
@Test
@@ -111,4 +124,39 @@ class ReaderScreenTest {
composeTestRule.onNodeWithText(seeMore(1)).assertIsDisplayed()
composeTestRule.onNodeWithText("b.pdf").assertDoesNotExist()
}
@Test
fun reader_reply_buildsQuotedReplyDraftAndOpensCompose() {
val repo = renderReader()
composeTestRule.onNodeWithText(string(R.string.reader_reply)).performClick()
composeTestRule.waitUntil(5_000) { openedDraftId != null }
// The reader routes through buildReplyDraft (quotes the original + bakes the signature),
// not a bare compose prefill (#303), and opens compose on the resulting draft.
assertEquals(listOf(messageId to ReplyMode.REPLY), repo.replyDrafts)
assertEquals("draft-$messageId", openedDraftId)
}
@Test
fun reader_forward_viaOverflow_buildsForwardDraft() {
val repo = renderReader()
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_forward)).performClick()
composeTestRule.waitUntil(5_000) { openedDraftId != null }
assertEquals(listOf(messageId to ReplyMode.FORWARD), repo.replyDrafts)
}
@Test
fun reader_replyAll_viaOverflow_buildsReplyAllDraft() {
val repo = renderReader()
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_reply_all)).performClick()
composeTestRule.waitUntil(5_000) { openedDraftId != null }
assertEquals(listOf(messageId to ReplyMode.REPLY_ALL), repo.replyDrafts)
}
}
@@ -126,7 +126,7 @@ fun LibreMailApp(
) {
ReaderScreen(
onBack = navController::popBackStack,
onReply = { to, subject, from -> navController.navigate(Routes.compose(to, subject, from)) },
onOpenCompose = { draftId -> navController.navigate(Routes.composeDraft(draftId)) },
)
}
composable(
@@ -28,8 +28,11 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -66,13 +69,14 @@ import org.libremail.R
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.InlineImage
import org.libremail.domain.model.Message
import org.libremail.domain.model.ReplyMode
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderScreen(
onBack: () -> Unit,
onReply: (to: String, subject: String, from: String) -> Unit,
onOpenCompose: (draftId: String) -> Unit,
viewModel: ReaderViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -80,6 +84,7 @@ fun ReaderScreen(
val snackbarHostState = remember { SnackbarHostState() }
val noAppMessage = stringResource(R.string.attachment_no_app)
val downloadFailedTemplate = stringResource(R.string.attachment_download_failed)
val replyFailedMessage = stringResource(R.string.reader_reply_failed)
LaunchedEffect(state.deleted) {
if (state.deleted) onBack()
@@ -95,6 +100,11 @@ fun ReaderScreen(
is ReaderEvent.DownloadFailed ->
snackbarHostState.showSnackbar(downloadFailedTemplate.format(event.name))
is ReaderEvent.OpenCompose -> onOpenCompose(event.draftId)
is ReaderEvent.ComposeFailed ->
snackbarHostState.showSnackbar(event.message ?: replyFailedMessage)
}
}
}
@@ -115,11 +125,17 @@ fun ReaderScreen(
actions = {
val message = state.message
if (message != null) {
TextButton(onClick = {
onReply(message.senderEmail, "Re: ${message.subject}", message.accountId)
}) {
TextButton(
onClick = { viewModel.reply(ReplyMode.REPLY) },
enabled = !state.composing,
) {
Text(stringResource(R.string.reader_reply))
}
ReplyOverflow(
enabled = !state.composing,
onReplyAll = { viewModel.reply(ReplyMode.REPLY_ALL) },
onForward = { viewModel.reply(ReplyMode.FORWARD) },
)
IconButton(onClick = viewModel::toggleStar) {
Icon(
Icons.Filled.Star,
@@ -164,6 +180,35 @@ fun ReaderScreen(
}
}
/**
* Overflow menu holding the reader's secondary reply actions — Reply All and Forward — so the app bar
* keeps Reply as its one prominent action (#303). Disabled while a draft is being built so a rapid tap
* can't kick off a second one before the first navigates to compose.
*/
@Composable
private fun ReplyOverflow(enabled: Boolean, onReplyAll: () -> Unit, onForward: () -> Unit) {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = true }, enabled = enabled) {
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.action_more))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.action_reply_all)) },
onClick = {
expanded = false
onReplyAll()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.action_forward)) },
onClick = {
expanded = false
onForward()
},
)
}
}
@Composable
private fun MessageBody(
message: Message,
@@ -17,6 +17,7 @@ import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.InlineImage
import org.libremail.domain.model.Message
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
import java.io.File
@@ -33,13 +34,21 @@ data class ReaderUiState(
val downloaded: Set<Int> = emptySet(),
val loadRemoteImages: Boolean = false,
val deleted: Boolean = false,
/** True while a reply/forward draft is being built (a brief network round-trip); gates re-entry. */
val composing: Boolean = false,
val error: String? = null,
)
/** One-shot effects the reader screen acts on (launching a viewer, showing a message). */
/** One-shot effects the reader screen acts on (launching a viewer, opening compose, showing a message). */
sealed interface ReaderEvent {
data class OpenFile(val file: File, val mimeType: String, val name: String) : ReaderEvent
data class DownloadFailed(val name: String) : ReaderEvent
/** A reply/forward draft was built; the screen opens compose on it. */
data class OpenCompose(val draftId: String) : ReaderEvent
/** Building the reply/forward draft failed; the screen surfaces [message] (or a generic fallback). */
data class ComposeFailed(val message: String?) : ReaderEvent
}
@HiltViewModel
@@ -122,6 +131,24 @@ class ReaderViewModel @Inject constructor(
fun loadRemoteImages() = _state.update { it.copy(loadRemoteImages = true) }
/**
* Builds a reply/reply-all/forward draft from the open message and opens compose on it — the same
* high-fidelity path the mailbox uses (quotes the original into a `<blockquote>`, bakes the
* signature, prefixes Re:/Fwd:), instead of a bare prefill (#303). [composing] is flipped
* synchronously before the launch so a double-tap can't build two drafts.
*/
fun reply(mode: ReplyMode) {
if (_state.value.composing) return
_state.update { it.copy(composing = true) }
viewModelScope.launch {
repository.buildReplyDraft(messageId, mode).fold(
onSuccess = { draftId -> _events.send(ReaderEvent.OpenCompose(draftId)) },
onFailure = { e -> _events.send(ReaderEvent.ComposeFailed(e.message)) },
)
_state.update { it.copy(composing = false) }
}
}
fun delete() {
viewModelScope.launch {
repository.deleteMessage(messageId)
@@ -43,6 +43,7 @@ fun ProblemReportsScreen(
viewModel: ProblemReportsViewModel = hiltViewModel(),
) {
val reports by viewModel.reports.collectAsStateWithLifecycle()
val creating by viewModel.creating.collectAsStateWithLifecycle()
// A newly created manual report opens straight into review.
LaunchedEffect(Unit) {
@@ -71,6 +72,7 @@ fun ProblemReportsScreen(
) {
Button(
onClick = viewModel::createManualReport,
enabled = !creating,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
@@ -5,9 +5,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
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
@@ -34,11 +36,24 @@ class ProblemReportsViewModel @Inject constructor(
private val _created = MutableSharedFlow<String>(extraBufferCapacity = 1)
val created: SharedFlow<String> = _created
/** True while a manual report is being collected; gates the button against a double-tap (#304). */
private val _creating = MutableStateFlow(false)
val creating: StateFlow<Boolean> = _creating.asStateFlow()
fun createManualReport() {
// Flip [_creating] synchronously before the launch so a double-tap can't collect and save two
// reports (and emit two "open review" navigations). Reset on completion — unlike a save-and-pop
// screen, this list stays put, so the user may legitimately create another later (#304).
if (_creating.value) return
_creating.value = true
viewModelScope.launch {
val report = collector.collectManual()
store.save(report)
_created.tryEmit(report.id)
try {
val report = collector.collectManual()
store.save(report)
_created.tryEmit(report.id)
} finally {
_creating.value = false
}
}
}
@@ -111,19 +111,20 @@ class ReportReviewViewModel @Inject constructor(
* still considers actionable.
*/
fun submit() {
// Re-entry guard + validation run SYNCHRONOUSLY, and SUBMITTING is flipped BEFORE the async
// save/enqueue, so a double-tap can't launch two uploads before the first flips the flag (#304).
if (submitState.value == SubmitUiState.SUBMITTING) return
if (!ReportSubmissionRules.isCommentLongEnough(comment.value)) return
if (!ReportSubmissionRules.isValidEmail(email.value)) return
val report = store.find(reportId) ?: return
submitState.value = SubmitUiState.SUBMITTING
viewModelScope.launch {
val currentSubmit = submitState.value
if (currentSubmit == SubmitUiState.SUBMITTING) return@launch
if (!ReportSubmissionRules.isCommentLongEnough(comment.value)) return@launch
if (!ReportSubmissionRules.isValidEmail(email.value)) return@launch
val report = store.find(reportId) ?: return@launch
store.save(report.copy(userComment = comment.value, userEmail = email.value))
if (!submitter.isEnabled) {
submitState.value = SubmitUiState.UNAVAILABLE
return@launch
}
submitter.submit(reportId)
submitState.value = SubmitUiState.SUBMITTING
submitter.status(reportId).collect { submitState.value = it.toUi() }
}
}
@@ -40,6 +40,7 @@ fun AccountSettingsScreen(
val signatureCount by viewModel.signatureCount.collectAsStateWithLifecycle()
val defaultSignatureName by viewModel.defaultSignatureName.collectAsStateWithLifecycle()
val isDefaultAccount by viewModel.isDefaultAccount.collectAsStateWithLifecycle()
val removing by viewModel.removing.collectAsStateWithLifecycle()
val context = LocalContext.current
val fallbackTitle = stringResource(R.string.settings_account_title)
@@ -116,6 +117,7 @@ fun AccountSettingsScreen(
ClickRow(
title = stringResource(R.string.account_remove),
titleColor = MaterialTheme.colorScheme.error,
enabled = !removing,
onClick = { viewModel.removeAccount(onBack) },
)
}
@@ -5,8 +5,10 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
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
@@ -60,6 +62,10 @@ class AccountSettingsViewModel @Inject constructor(
/** This account's notification channel id, for deep-linking into Android's system settings. */
val notificationChannelId: String = MailNotifier.channelId(accountId)
/** True once account removal is in flight; gates the button so a double-tap can't over-pop (#304). */
private val _removing = MutableStateFlow(false)
val removing: StateFlow<Boolean> = _removing.asStateFlow()
fun setSignatureEnabled(value: Boolean) {
viewModelScope.launch { accountSettingsRepository.setSignatureEnabled(accountId, value) }
}
@@ -101,6 +107,10 @@ class AccountSettingsViewModel @Inject constructor(
}
fun removeAccount(onRemoved: () -> Unit) {
// [_removing] is flipped synchronously before the launch so a rapid double-tap can't delete
// twice and fire [onRemoved] (a back-navigation) twice, over-popping past the mailbox (#304).
if (_removing.value) return
_removing.value = true
viewModelScope.launch {
accountRepository.deleteAccount(accountId)
// Don't strand the preference on a deleted account; only clears it if this account was
@@ -62,11 +62,12 @@ internal fun ClickRow(
onClick: () -> Unit,
subtitle: String? = null,
titleColor: Color = Color.Unspecified,
enabled: Boolean = true,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -52,7 +52,7 @@ fun SignatureEditScreen(onBack: () -> Unit, viewModel: SignatureEditViewModel =
}
},
actions = {
TextButton(onClick = { viewModel.save(onBack) }) {
TextButton(onClick = { viewModel.save(onBack) }, enabled = !state.saving) {
Text(stringResource(R.string.signature_save))
}
},
@@ -21,6 +21,8 @@ data class SignatureEditUiState(
val body: String = "",
val bodyHtml: String? = null,
val loaded: Boolean = false,
/** True once a save is in flight; gates the Save button so a double-tap can't create two signatures. */
val saving: Boolean = false,
)
@HiltViewModel
@@ -59,12 +61,18 @@ class SignatureEditViewModel @Inject constructor(
fun onBodyChange(plain: String, html: String?) = _state.update { it.copy(body = plain, bodyHtml = html) }
/** Persists the signature (create or update), then invokes [onSaved]. */
/**
* Persists the signature (create or update), then invokes [onSaved]. Re-entrant taps are dropped:
* [SignatureEditUiState.saving] is flipped synchronously before the launch, so a rapid double-tap on
* a new signature can't create two rows (and fire [onSaved] twice) (#304).
*/
fun save(onSaved: () -> Unit) {
if (_state.value.saving) return
val s = _state.value
val name = s.name.trim().ifBlank { DEFAULT_NAME }
// Store real HTML so the signature round-trips; derive it from the plaintext when unformatted.
val html = s.bodyHtml ?: RichTextHtml.toHtml(RichTextContent(s.body))
_state.update { it.copy(saving = true) }
viewModelScope.launch {
if (signatureId == null) {
signatureRepository.create(accountId, name, html)
+1
View File
@@ -150,6 +150,7 @@
<!-- Reader -->
<string name="reader_reply">Reply</string>
<string name="reader_reply_failed">Couldn\'t start the reply</string>
<string name="attachments_title">Attachments</string>
<string name="attachment_downloaded">Downloaded</string>
<string name="message_available_offline">Available offline</string>
@@ -3,10 +3,12 @@ package org.libremail.ui.reader
import androidx.lifecycle.SavedStateHandle
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
@@ -21,6 +23,7 @@ import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.InlineImage
import org.libremail.domain.model.Message
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
import java.io.File
@@ -104,4 +107,72 @@ class ReaderViewModelTest {
assertTrue(0 in vm.state.value.downloaded)
}
/** Builds a reader whose message loads and whose [MailRepository.buildReplyDraft] returns [draftId]. */
private fun replyViewModel(repo: MailRepository, mode: ReplyMode, draftId: String = "draft-x"): ReaderViewModel {
coEvery { repo.openMessage(messageId) } returns Result.success(message)
every { repo.observeAttachments(messageId) } returns flowOf(emptyList())
coEvery { repo.downloadedAttachmentParts(messageId) } returns emptySet()
coEvery { repo.buildReplyDraft(messageId, mode) } returns Result.success(draftId)
return viewModel(repo)
}
@Test
fun `reply routes through buildReplyDraft and emits OpenCompose`() = runTest(dispatcher) {
// #303: the open-email reply must reuse the mailbox's high-fidelity draft (quoted original +
// signature), not a bare prefill — so it goes through buildReplyDraft and opens that draft.
val repo = mockk<MailRepository>(relaxed = true)
val vm = replyViewModel(repo, ReplyMode.REPLY, draftId = "draft-42")
advanceUntilIdle()
vm.reply(ReplyMode.REPLY)
advanceUntilIdle()
assertEquals(ReaderEvent.OpenCompose("draft-42"), vm.events.first())
coVerify(exactly = 1) { repo.buildReplyDraft(messageId, ReplyMode.REPLY) }
}
@Test
fun `forward passes the FORWARD mode to buildReplyDraft`() = runTest(dispatcher) {
val repo = mockk<MailRepository>(relaxed = true)
val vm = replyViewModel(repo, ReplyMode.FORWARD)
advanceUntilIdle()
vm.reply(ReplyMode.FORWARD)
advanceUntilIdle()
coVerify(exactly = 1) { repo.buildReplyDraft(messageId, ReplyMode.FORWARD) }
}
@Test
fun `a rapid double-tap on reply builds only one draft`() = runTest(dispatcher) {
// #304: composing is flipped synchronously, so the second tap (before the first draft resolves)
// is dropped rather than building a second draft and opening compose twice.
val repo = mockk<MailRepository>(relaxed = true)
val vm = replyViewModel(repo, ReplyMode.REPLY)
advanceUntilIdle()
vm.reply(ReplyMode.REPLY)
vm.reply(ReplyMode.REPLY)
advanceUntilIdle()
coVerify(exactly = 1) { repo.buildReplyDraft(messageId, ReplyMode.REPLY) }
}
@Test
fun `a failed reply emits ComposeFailed and clears the composing flag`() = runTest(dispatcher) {
val repo = mockk<MailRepository>(relaxed = true)
coEvery { repo.openMessage(messageId) } returns Result.success(message)
every { repo.observeAttachments(messageId) } returns flowOf(emptyList())
coEvery { repo.downloadedAttachmentParts(messageId) } returns emptySet()
coEvery { repo.buildReplyDraft(messageId, ReplyMode.REPLY) } returns Result.failure(RuntimeException("boom"))
val vm = viewModel(repo)
advanceUntilIdle()
vm.reply(ReplyMode.REPLY)
advanceUntilIdle()
assertEquals(ReaderEvent.ComposeFailed("boom"), vm.events.first())
assertEquals(false, vm.state.value.composing)
}
}
@@ -4,6 +4,7 @@ package org.libremail.ui.reporting
import app.cash.turbine.test
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@@ -12,6 +13,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@@ -92,6 +94,29 @@ class ProblemReportsViewModelTest {
verify(exactly = 1) { store.save(match { it.id == "fresh" }) }
}
@Test
fun `a rapid double-tap on Create makes only one report`() {
// Runs on a StandardTestDispatcher so the collect coroutine is queued: the second tap lands
// before it runs, exercising the synchronous `creating` guard so only one report is made (#304).
val standardMain = StandardTestDispatcher()
Dispatchers.setMain(standardMain)
val store = mockk<ReportStore>(relaxed = true)
every { store.reports } returns MutableStateFlow(emptyList())
every { store.save(any()) } just Runs
val collector = mockk<DiagnosticsCollector>()
coEvery { collector.collectManual() } returns report("fresh")
runTest(standardMain) {
val vm = ProblemReportsViewModel(store, collector)
vm.createManualReport()
vm.createManualReport() // double-tap before the first collect runs
advanceUntilIdle()
coVerify(exactly = 1) { collector.collectManual() }
verify(exactly = 1) { store.save(match { it.id == "fresh" }) }
}
}
@Test
fun `discard deletes the report from the store`() = runTest(dispatcher) {
val store = mockk<ReportStore>(relaxed = true)
@@ -11,7 +11,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
@@ -112,6 +114,31 @@ class ReportReviewViewModelTest {
verify(exactly = 1) { submitter.submit("rid") }
}
@Test
fun `a rapid double-tap on Submit enqueues the upload only once`() {
// Runs on a StandardTestDispatcher so the submit coroutine is queued: the second tap lands
// before it runs. The fix flips SUBMITTING synchronously (before the async save), so the
// second tap is dropped instead of enqueueing a second upload (#304).
val standardMain = StandardTestDispatcher()
Dispatchers.setMain(standardMain)
every { submitter.isEnabled } returns true
every { submitter.submit("rid") } just Runs
every { submitter.status("rid") } returns emptyFlow()
every { store.save(any()) } just Runs
runTest(standardMain) {
val vm = viewModel()
vm.updateComment(VALID_COMMENT)
vm.updateEmail(VALID_EMAIL)
vm.submit()
vm.submit() // double-tap before the first submit is dispatched
advanceUntilIdle()
verify(exactly = 1) { submitter.submit("rid") }
verify(exactly = 1) { store.save(any()) }
}
}
@Test
fun `submit with no endpoint configured never transmits but still persists`() = runTest(testDispatcher) {
every { submitter.isEnabled } returns false
@@ -10,6 +10,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@@ -253,6 +254,30 @@ class AccountSettingsViewModelTest {
assertEquals(true, removed)
}
@Test
fun `a rapid double-tap on Remove deletes the account and navigates back only once`() {
// Runs on a StandardTestDispatcher so the delete coroutine is queued: the second tap lands
// before it runs, exercising the synchronous `removing` guard. Otherwise a double onBack would
// over-pop past the mailbox (#304).
val standardMain = StandardTestDispatcher()
Dispatchers.setMain(standardMain)
val accounts = mockk<AccountRepository>(relaxed = true)
every { accounts.observeAccounts() } returns MutableStateFlow(listOf(account))
val settingsRepository = mockk<SettingsRepository>(relaxed = true)
every { settingsRepository.settings } returns MutableStateFlow(AppSettings())
var removedCount = 0
runTest(standardMain) {
val vm = viewModel(accountRepository = accounts, settingsRepository = settingsRepository)
vm.removeAccount { removedCount++ }
vm.removeAccount { removedCount++ } // double-tap before the first delete runs
advanceUntilIdle()
coVerify(exactly = 1) { accounts.deleteAccount(ACCOUNT) }
assertEquals(1, removedCount)
}
}
private companion object {
const val ACCOUNT = "imap:me@example.org"
}
@@ -9,6 +9,7 @@ import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@@ -148,6 +149,28 @@ class SignatureEditViewModelTest {
coVerify { repo.create(ACCOUNT, "Trimmed", "<i>body</i>") }
}
@Test
fun `a rapid double-tap on Save creates the signature only once`() {
// Runs on a StandardTestDispatcher so the save coroutine is queued (not run eagerly): the
// second tap lands before it runs, exercising the synchronous `saving` guard (#304).
val standardMain = StandardTestDispatcher()
Dispatchers.setMain(standardMain)
val repo = mockk<SignatureRepository>(relaxed = true)
coEvery { repo.create(any(), any(), any()) } returns "new-id"
var savedCount = 0
runTest(standardMain) {
val vm = viewModel(repo)
vm.onBodyChange("body", null)
vm.save { savedCount++ }
vm.save { savedCount++ } // double-tap before the first save is dispatched
advanceUntilIdle()
coVerify(exactly = 1) { repo.create(any(), any(), any()) }
assertEquals(1, savedCount)
}
}
@Test
fun `saving an existing signature updates it and invokes the callback`() = runTest(dispatcher) {
val repo = mockk<SignatureRepository>(relaxed = true)