diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt index 97777c4..b59a1c9 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt @@ -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) + } } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index 3acb89f..de03f66 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -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, selectedId: String?, onSelect: (Str @Composable private fun AttachmentsSection( attachments: List, + 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)) diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 2ceff23..3856d70 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -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") } + }, + ) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da43daa..879b7d6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,6 +45,8 @@ More options Move to Cancel + Yes + No Move to Spam? Move %1$d message(s) to the Spam folder? Move to Trash? @@ -53,6 +55,8 @@ This permanently deletes %1$d message(s) and can\'t be undone. Reply to all? Reply to everyone included on this message? + Need to attach anything? + Your message mentions an attachment, but nothing is attached. Show folders diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt index da82df2..085224a 100644 --- a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt @@ -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(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(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(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(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(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(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(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()) } + } }