feat(compose): prompt before sending when a mentioned attachment is missing

Send now scans the subject and body for "attach" and its variants
(word-bounded, case-insensitive). When the text mentions one but the
message carries no attachment, an AlertDialog asks "Need to attach
anything?" — Yes returns to composing and pulses the attach button,
No sends the message as-is, and dismissing cancels the send. (#79)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 17:04:15 -05:00
co-authored by Claude Fable 5
parent f5c9d4c4d2
commit 671fca99a2
5 changed files with 253 additions and 32 deletions
@@ -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.junit4.createAndroidComposeRule
@@ -16,6 +17,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
@@ -118,4 +120,26 @@ 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)
}
}
@@ -10,6 +10,8 @@ import android.provider.OpenableColumns
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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
@@ -28,6 +30,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
@@ -182,6 +186,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,
)
@@ -207,6 +213,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
@@ -249,11 +278,32 @@ private fun FromRow(accounts: List<Account>, selectedId: String?, onSelect: (Str
@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 {
@@ -260,7 +267,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
@@ -270,37 +294,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") }
},
)
}
}
+4
View File
@@ -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
@@ -211,4 +213,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()) }
}
}