feat(compose): prompt before sending when a mentioned attachment is missing #80
@@ -3,6 +3,7 @@ package org.libremail.ui.compose
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
@@ -18,6 +19,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -121,6 +123,28 @@ class ComposeScreenTest {
|
||||
composeTestRule.waitUntil(timeoutMillis = 5_000) { closed }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun send_whenBodyMentionsAttachmentWithoutOne_promptsBeforeSending() {
|
||||
val mailRepository = FakeMailRepository()
|
||||
setContent(mailRepository)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("I attached the report")
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
|
||||
|
||||
// "Yes" returns to composing: the dialog closes and nothing is sent.
|
||||
composeTestRule.onNodeWithText(string(R.string.confirm_attachment_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_yes)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.confirm_attachment_title)).assertDoesNotExist()
|
||||
assertTrue(mailRepository.sentMessages.isEmpty())
|
||||
|
||||
// Sending again and answering "No" delivers the message as-is.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_no)).performClick()
|
||||
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
|
||||
assertEquals("I attached the report", mailRepository.sentMessages.single().body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ccAndBcc_startCollapsed_expandViaLinksAndCarryThroughSend() {
|
||||
val mailRepository = FakeMailRepository()
|
||||
|
||||
@@ -11,6 +11,8 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -29,6 +31,8 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -173,6 +177,8 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
|
||||
)
|
||||
AttachmentsSection(
|
||||
attachments = state.attachments,
|
||||
highlight = state.highlightAttach,
|
||||
onHighlightShown = viewModel::consumeAttachHighlight,
|
||||
onAttach = { attachmentPicker.launch(arrayOf("*/*")) },
|
||||
onRemove = viewModel::removeAttachment,
|
||||
)
|
||||
@@ -198,6 +204,29 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.showAttachmentPrompt) {
|
||||
AttachmentPromptDialog(
|
||||
onAttach = viewModel::attachInstead,
|
||||
onSendAnyway = viewModel::sendAnyway,
|
||||
onDismiss = viewModel::dismissAttachmentPrompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when Send is tapped on a message that mentions an attachment but carries none. "Yes"
|
||||
* returns to composing with the attach button highlighted; "No" sends the message as-is.
|
||||
*/
|
||||
@Composable
|
||||
private fun AttachmentPromptDialog(onAttach: () -> Unit, onSendAnyway: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.confirm_attachment_title)) },
|
||||
text = { Text(stringResource(R.string.confirm_attachment_text)) },
|
||||
confirmButton = { TextButton(onClick = onAttach) { Text(stringResource(R.string.action_yes)) } },
|
||||
dismissButton = { TextButton(onClick = onSendAnyway) { Text(stringResource(R.string.action_no)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -315,11 +344,32 @@ private fun RecipientField(
|
||||
@Composable
|
||||
private fun AttachmentsSection(
|
||||
attachments: List<OutgoingAttachment>,
|
||||
highlight: Boolean,
|
||||
onHighlightShown: () -> Unit,
|
||||
onAttach: () -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
) {
|
||||
// Answering "Yes" on the attachment prompt lands back here: pulse the attach button a few
|
||||
// times to draw the eye, then report the highlight as consumed.
|
||||
val pulse = remember { Animatable(0f) }
|
||||
LaunchedEffect(highlight) {
|
||||
if (highlight) {
|
||||
repeat(3) {
|
||||
pulse.animateTo(1f, tween(durationMillis = 300))
|
||||
pulse.animateTo(0f, tween(durationMillis = 300))
|
||||
}
|
||||
onHighlightShown()
|
||||
} else {
|
||||
pulse.snapTo(0f)
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
TextButton(onClick = onAttach) {
|
||||
TextButton(
|
||||
onClick = onAttach,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = pulse.value),
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.compose_attach))
|
||||
|
||||
@@ -48,6 +48,10 @@ data class ComposeUiState(
|
||||
val contactsAllowed: Boolean = false,
|
||||
val sending: Boolean = false,
|
||||
val error: String? = null,
|
||||
/** Send was tapped while the text mentions an attachment but none is attached: ask first. */
|
||||
val showAttachmentPrompt: Boolean = false,
|
||||
/** Draw the eye to the attach button — the user answered the attachment prompt with "Yes". */
|
||||
val highlightAttach: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -90,6 +94,9 @@ class ComposeViewModel @Inject constructor(
|
||||
/** The signature block last appended to the body, so a From-change can swap it out cleanly. */
|
||||
private var appliedSignatureBlock = SignatureBlock.EMPTY
|
||||
|
||||
/** Word-bounded "attach" and variants (attached, attachment(s), attaching, attaches). */
|
||||
private val attachmentMention = Regex("""\battach(?:ed|ment|ments|ing|es)?\b""", RegexOption.IGNORE_CASE)
|
||||
|
||||
init {
|
||||
if (draftId != null) {
|
||||
viewModelScope.launch {
|
||||
@@ -261,7 +268,24 @@ class ComposeViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun send() {
|
||||
fun send() = trySend(checkAttachments = true)
|
||||
|
||||
/** "No" on the attachment prompt — the user confirmed nothing needs attaching, so send as-is. */
|
||||
fun sendAnyway() {
|
||||
_state.update { it.copy(showAttachmentPrompt = false) }
|
||||
trySend(checkAttachments = false)
|
||||
}
|
||||
|
||||
/** "Yes" on the attachment prompt — back to composing, with the attach button highlighted. */
|
||||
fun attachInstead() = _state.update { it.copy(showAttachmentPrompt = false, highlightAttach = true) }
|
||||
|
||||
/** The prompt was dismissed without choosing: stay composing, no send, no highlight. */
|
||||
fun dismissAttachmentPrompt() = _state.update { it.copy(showAttachmentPrompt = false) }
|
||||
|
||||
/** The attach-button highlight has finished animating. */
|
||||
fun consumeAttachHighlight() = _state.update { it.copy(highlightAttach = false) }
|
||||
|
||||
private fun trySend(checkAttachments: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val s = _state.value
|
||||
// Await the account list if it hasn't emitted yet, so an early tap doesn't wrongly
|
||||
@@ -271,37 +295,39 @@ class ComposeViewModel @Inject constructor(
|
||||
when {
|
||||
account == null -> _state.update { it.copy(error = "Add an account first") }
|
||||
s.to.isBlank() -> _state.update { it.copy(error = "Add a recipient") }
|
||||
else -> {
|
||||
_state.update { it.copy(sending = true, error = null) }
|
||||
mailRepository.sendMessage(
|
||||
OutgoingMessage(
|
||||
accountId = account.id,
|
||||
to = s.to,
|
||||
cc = s.cc,
|
||||
bcc = s.bcc,
|
||||
subject = s.subject,
|
||||
body = s.body,
|
||||
bodyHtml = s.bodyHtml,
|
||||
attachments = s.attachments,
|
||||
),
|
||||
).fold(
|
||||
onSuccess = {
|
||||
draftId?.let { mailRepository.deleteDraft(it) }
|
||||
_state.update { it.copy(sending = false) }
|
||||
finish()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
sending = false,
|
||||
error =
|
||||
e.message ?: "Could not send",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
checkAttachments && s.attachments.isEmpty() && mentionsAttachment(s) ->
|
||||
_state.update { it.copy(showAttachmentPrompt = true) }
|
||||
else -> performSend(account, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The classic forgotten-attachment guard: does the subject or body talk about attaching? */
|
||||
private fun mentionsAttachment(s: ComposeUiState): Boolean =
|
||||
attachmentMention.containsMatchIn(s.subject) || attachmentMention.containsMatchIn(s.body)
|
||||
|
||||
private suspend fun performSend(account: Account, s: ComposeUiState) {
|
||||
_state.update { it.copy(sending = true, error = null) }
|
||||
mailRepository.sendMessage(
|
||||
OutgoingMessage(
|
||||
accountId = account.id,
|
||||
to = s.to,
|
||||
cc = s.cc,
|
||||
bcc = s.bcc,
|
||||
subject = s.subject,
|
||||
body = s.body,
|
||||
bodyHtml = s.bodyHtml,
|
||||
attachments = s.attachments,
|
||||
),
|
||||
).fold(
|
||||
onSuccess = {
|
||||
draftId?.let { mailRepository.deleteDraft(it) }
|
||||
_state.update { it.copy(sending = false) }
|
||||
finish()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_state.update { it.copy(sending = false, error = e.message ?: "Could not send") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
<string name="action_more">More options</string>
|
||||
<string name="move_picker_title">Move to</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="action_yes">Yes</string>
|
||||
<string name="action_no">No</string>
|
||||
<string name="confirm_spam_title">Move to Spam?</string>
|
||||
<string name="confirm_spam_text">Move %1$d message(s) to the Spam folder?</string>
|
||||
<string name="confirm_trash_title">Move to Trash?</string>
|
||||
@@ -53,6 +55,8 @@
|
||||
<string name="confirm_delete_text">This permanently deletes %1$d message(s) and can\'t be undone.</string>
|
||||
<string name="confirm_reply_all_title">Reply to all?</string>
|
||||
<string name="confirm_reply_all_text">Reply to everyone included on this message?</string>
|
||||
<string name="confirm_attachment_title">Need to attach anything?</string>
|
||||
<string name="confirm_attachment_text">Your message mentions an attachment, but nothing is attached.</string>
|
||||
|
||||
<!-- Folder navigation drawer -->
|
||||
<string name="drawer_open">Show folders</string>
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.Draft
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.OutgoingAttachment
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.domain.model.Signature
|
||||
@@ -31,6 +32,7 @@ import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@@ -232,4 +234,119 @@ class ComposeViewModelTest {
|
||||
coVerify { mailRepository.sendMessage(capture(sent)) }
|
||||
assertEquals("secret@example.org", sent.captured.bcc)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asks about attachments when the body mentions one but none is attached`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("I attached the report.", null)
|
||||
vm.send()
|
||||
|
||||
assertTrue(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asks about attachments when only the subject mentions one`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onSubjectChange("Contract attachment")
|
||||
vm.send()
|
||||
|
||||
assertTrue(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matches attachment variants but not lookalike words`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
|
||||
listOf("Attached is the file", "attaching it now", "see the ATTACHMENTS", "can you attach it").forEach {
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange(it, null)
|
||||
vm.send()
|
||||
assertTrue(vm.state.value.showAttachmentPrompt, "should prompt for: $it")
|
||||
}
|
||||
|
||||
listOf("planning an attack", "the base is attachable", "no keyword here").forEach {
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange(it, null)
|
||||
vm.send()
|
||||
assertFalse(vm.state.value.showAttachmentPrompt, "should not prompt for: $it")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sends without asking when an attachment is present`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("The report is attached.", null)
|
||||
vm.addAttachments(listOf(OutgoingAttachment("content://docs/report.pdf", "report.pdf")))
|
||||
vm.send()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 1) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendAnyway sends the message the prompt held back`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("I attached the report.", null)
|
||||
vm.send()
|
||||
assertTrue(vm.state.value.showAttachmentPrompt)
|
||||
|
||||
vm.sendAnyway()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 1) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachInstead returns to composing and highlights the attach button`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("Attachment coming.", null)
|
||||
vm.send()
|
||||
|
||||
vm.attachInstead()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
assertTrue(vm.state.value.highlightAttach)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
|
||||
vm.consumeAttachHighlight()
|
||||
assertFalse(vm.state.value.highlightAttach)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissing the prompt cancels the send without highlighting`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("See attached.", null)
|
||||
vm.send()
|
||||
|
||||
vm.dismissAttachmentPrompt()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
assertFalse(vm.state.value.highlightAttach)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user