feat(reporting): require a reply-to email and a 200-char minimum on problem reports

Adds friction to the "Report a Problem" form: a required email field (basic
local-part@domain.tld validation), a required consent notice about being
contacted at that address, and a 200-character minimum on the comment field
with a live "x/200" counter that turns red (with the field outline) until the
threshold is met. Submit stays disabled until both the comment and email are
valid, mirroring and extending the existing SUBMITTING gate. The email rides
along on DebugReport (userEmail) so it round-trips through the storage JSON
and the exact payload that's previewed, copied, saved, and POSTed. The new
ViewModel-level guard on submit() also fully integrates with the #161
success-confirmation dialog: invalid attempts never reach SUBMITTING/SUCCEEDED,
so the dialog flow is unaffected.

Closes #159

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 18:52:21 -05:00
co-authored by Claude Opus 4.8
parent 5f1e611886
commit 2151bc6d7c
6 changed files with 230 additions and 21 deletions
@@ -29,6 +29,8 @@ data class DebugReport(
val settings: Map<String, String>,
val logs: List<String>,
val userComment: String = "",
/** Reply-to address the user supplied when submitting (see #159); required for online submit. */
val userEmail: String = "",
) {
/** The exact text shown for review, copied, saved to a file, and POSTed on submit. */
fun toSubmissionPayload(): String = toJson().toString(JSON_INDENT)
@@ -55,6 +57,7 @@ data class DebugReport(
.put("app", app)
.put("device", device)
.put("userComment", userComment)
.put("userEmail", userEmail)
.put("settings", settingsJson)
.put("logs", JSONArray(logs))
if (stackTrace != null) json.put("stackTrace", stackTrace)
@@ -88,6 +91,7 @@ data class DebugReport(
settings = settings,
logs = logs,
userComment = json.optString("userComment", ""),
userEmail = json.optString("userEmail", ""),
)
}
}
@@ -128,6 +128,36 @@ fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hi
label = { Text(stringResource(R.string.report_comment_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
isError = !state.isCommentLongEnough,
supportingText = {
Text(
stringResource(
R.string.report_comment_counter,
state.comment.length,
ReportSubmissionRules.MIN_COMMENT_LENGTH,
),
)
},
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.email,
onValueChange = viewModel::updateEmail,
label = { Text(stringResource(R.string.report_email_label)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = !state.isEmailValid,
supportingText = {
if (!state.isEmailValid) {
Text(stringResource(R.string.report_email_invalid))
}
},
)
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.report_email_consent),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Text(
@@ -141,7 +171,9 @@ fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hi
Spacer(Modifier.height(16.dp))
Button(
onClick = viewModel::submit,
enabled = state.submit != SubmitUiState.SUBMITTING,
enabled = state.submit != SubmitUiState.SUBMITTING &&
state.isCommentLongEnough &&
state.isEmailValid,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.report_submit))
@@ -20,14 +20,45 @@ import javax.inject.Inject
/** UI-facing status of a submission attempt. [UNAVAILABLE] means no endpoint is configured. */
enum class SubmitUiState { IDLE, SUBMITTING, SUCCEEDED, FAILED, UNAVAILABLE }
/**
* Pure validation rules for problem-report submission (#159). Kept dependency-free — in particular,
* no `android.util.Patterns`, which is a no-op stub under plain JVM unit tests — so both the
* min-length and email-shape checks are directly unit-testable and shareable between
* [ReportReviewState] and [ReportReviewViewModel].
*/
object ReportSubmissionRules {
/** Minimum comment length (characters) required before Submit is enabled. */
const val MIN_COMMENT_LENGTH = 200
// Basic shape only: a non-blank local part, an `@`, and a domain with at least one `.` and
// non-blank labels either side of it. Deliberately not a full RFC 5322 validator.
private val EMAIL_REGEX = Regex("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")
/** Whether [comment] reaches [MIN_COMMENT_LENGTH]. */
fun isCommentLongEnough(comment: String): Boolean = comment.length >= MIN_COMMENT_LENGTH
/** Whether [email] has a plausible local-part@domain.tld shape. */
fun isValidEmail(email: String): Boolean = EMAIL_REGEX.matches(email.trim())
}
data class ReportReviewState(
val loaded: Boolean = false,
val exists: Boolean = false,
val payload: String = "",
val comment: String = "",
val email: String = "",
val canSubmitOnline: Boolean = false,
val submit: SubmitUiState = SubmitUiState.IDLE,
)
) {
/** True once [comment] reaches [ReportSubmissionRules.MIN_COMMENT_LENGTH]. */
val isCommentLongEnough: Boolean get() = ReportSubmissionRules.isCommentLongEnough(comment)
/** True once [email] is a plausible reply-to address. */
val isEmailValid: Boolean get() = ReportSubmissionRules.isValidEmail(email)
/** Submit gate: the original SUBMITTING check, plus the new length/email requirements. */
val canSubmit: Boolean get() = submit != SubmitUiState.SUBMITTING && isCommentLongEnough && isEmailValid
}
@HiltViewModel
class ReportReviewViewModel @Inject constructor(
@@ -38,17 +69,20 @@ class ReportReviewViewModel @Inject constructor(
private val reportId: String = checkNotNull(savedStateHandle[Routes.REPORT_REVIEW_ARG_ID])
private val comment = MutableStateFlow(store.find(reportId)?.userComment.orEmpty())
private val email = MutableStateFlow(store.find(reportId)?.userEmail.orEmpty())
private val submitState = MutableStateFlow(SubmitUiState.IDLE)
val state: StateFlow<ReportReviewState> =
combine(store.reports, comment, submitState) { reports, currentComment, submit ->
combine(store.reports, comment, email, submitState) { reports, currentComment, currentEmail, submit ->
val report = reports.firstOrNull { it.id == reportId }
ReportReviewState(
loaded = true,
exists = report != null,
// The comment is folded in so the preview is byte-for-byte what a submit would send.
payload = report?.copy(userComment = currentComment)?.toSubmissionPayload().orEmpty(),
// The comment/email are folded in so the preview is byte-for-byte what a submit would send.
payload = report?.copy(userComment = currentComment, userEmail = currentEmail)
?.toSubmissionPayload().orEmpty(),
comment = currentComment,
email = currentEmail,
canSubmitOnline = submitter.isEnabled,
submit = submit,
)
@@ -58,6 +92,10 @@ class ReportReviewViewModel @Inject constructor(
comment.value = value
}
fun updateEmail(value: String) {
email.value = value
}
fun discard() {
viewModelScope.launch { store.delete(reportId) }
}
@@ -66,11 +104,20 @@ class ReportReviewViewModel @Inject constructor(
* The only path that can send a report off-device, and only from an explicit Submit tap. Persists
* the reviewed comment first so the upload matches exactly what was shown, then enqueues the
* worker (unless no endpoint is configured, in which case it steers the user to Copy/Save).
*
* Guarded by [ReportSubmissionRules] directly (rather than trusting the caller) so this can't
* succeed with an under-length comment or an invalid email even if invoked outside the
* Compose button's `enabled` gate — e.g. from an accessibility service activating a control it
* still considers actionable.
*/
fun submit() {
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))
store.save(report.copy(userComment = comment.value, userEmail = email.value))
if (!submitter.isEnabled) {
submitState.value = SubmitUiState.UNAVAILABLE
return@launch
@@ -82,7 +129,8 @@ class ReportReviewViewModel @Inject constructor(
}
/** The exact text shown for review — used for Copy and Save-to-file. */
fun payload(): String = store.find(reportId)?.copy(userComment = comment.value)?.toSubmissionPayload().orEmpty()
fun payload(): String = store.find(reportId)?.copy(userComment = comment.value, userEmail = email.value)
?.toSubmissionPayload().orEmpty()
private fun SubmitStatus.toUi(): SubmitUiState = when (this) {
SubmitStatus.IDLE, SubmitStatus.SUBMITTING -> SubmitUiState.SUBMITTING
+7 -1
View File
@@ -330,7 +330,13 @@
<string name="report_review_title">Review report</string>
<string name="report_pii_disclaimer_title">May contain personal information</string>
<string name="report_pii_disclaimer">This report can include email addresses, server names, and other details from your device. Read the whole thing below before sending. Nothing is sent unless you tap Submit.</string>
<string name="report_comment_label">What went wrong? (optional)</string>
<string name="report_comment_label">What went wrong?</string>
<!-- Live "x/200" counter below the comment field; %1$d = characters typed, %2$d = minimum. -->
<string name="report_comment_counter">%1$d/%2$d</string>
<string name="report_email_label">Your email address</string>
<string name="report_email_invalid">Enter a valid email address</string>
<!-- Consent copy shown near the email field / submit button (#159) — required verbatim. -->
<string name="report_email_consent">By submitting this report and supplying your email, you agree that the maintainers of LibreMail may contact you at the supplied email. Supplying an email and submitting a report does not guarantee reply or resolution to your concern.</string>
<string name="report_payload_label">Exactly what will be sent</string>
<string name="report_submit">Submit</string>
<string name="report_discard">Discard report</string>
@@ -69,4 +69,28 @@ class DebugReportTest {
assertTrue(edited.contains("edited note"))
}
@Test
fun `round-trips the reply-to email`() {
val original = sample().copy(userEmail = "reporter@example.com")
val restored = DebugReport.fromStorageJson(original.toStorageJson())
assertEquals("reporter@example.com", restored.userEmail)
assertEquals(original, restored)
}
@Test
fun `a report with no email round-trips to a blank one`() {
val restored = DebugReport.fromStorageJson(sample().toStorageJson())
assertEquals("", restored.userEmail)
}
@Test
fun `submission payload contains the reply-to email`() {
val payload = sample().copy(userEmail = "reporter@example.com").toSubmissionPayload()
assertTrue(payload.contains("reporter@example.com"))
}
}
@@ -24,6 +24,8 @@ import org.libremail.reporting.ReportStore
import org.libremail.reporting.ReportSubmitter
import org.libremail.ui.navigation.Routes
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class ReportReviewViewModelTest {
@@ -83,31 +85,82 @@ class ReportReviewViewModelTest {
}
@Test
fun `submit enqueues the upload exactly once and persists the reviewed comment`() = runTest(testDispatcher) {
every { submitter.isEnabled } returns true
every { submitter.submit("rid") } just Runs
every { submitter.status("rid") } returns emptyFlow()
every { store.save(any()) } just Runs
fun `payload shown reflects the entered email`() = runTest(testDispatcher) {
val vm = viewModel()
vm.updateComment("edited before submit")
vm.submit()
vm.updateEmail("me@example.com")
verify(exactly = 1) { store.save(match { it.userComment == "edited before submit" }) }
verify(exactly = 1) { submitter.submit("rid") }
assertEquals(report.copy(userEmail = "me@example.com").toSubmissionPayload(), vm.payload())
}
@Test
fun `submit with no endpoint configured never transmits`() = runTest(testDispatcher) {
fun `submit enqueues the upload exactly once and persists the reviewed comment and email`() =
runTest(testDispatcher) {
every { submitter.isEnabled } returns true
every { submitter.submit("rid") } just Runs
every { submitter.status("rid") } returns emptyFlow()
every { store.save(any()) } just Runs
val vm = viewModel()
vm.updateComment(VALID_COMMENT)
vm.updateEmail(VALID_EMAIL)
vm.submit()
verify(exactly = 1) {
store.save(match { it.userComment == VALID_COMMENT && it.userEmail == VALID_EMAIL })
}
verify(exactly = 1) { submitter.submit("rid") }
}
@Test
fun `submit with no endpoint configured never transmits but still persists`() = runTest(testDispatcher) {
every { submitter.isEnabled } returns false
every { store.save(any()) } just Runs
val vm = viewModel()
vm.updateComment("please send")
vm.updateComment(VALID_COMMENT)
vm.updateEmail(VALID_EMAIL)
vm.submit()
// The comment is still persisted for Copy/Save, but nothing is enqueued for upload.
verify(exactly = 1) { store.save(match { it.userComment == "please send" }) }
// The comment/email are still persisted for Copy/Save, but nothing is enqueued for upload.
verify(exactly = 1) {
store.save(match { it.userComment == VALID_COMMENT && it.userEmail == VALID_EMAIL })
}
verify(exactly = 0) { submitter.submit(any()) }
}
@Test
fun `submit does nothing when the comment is under the minimum length`() = runTest(testDispatcher) {
val vm = viewModel()
vm.updateEmail(VALID_EMAIL)
vm.updateComment("way too short")
vm.submit()
verify(exactly = 0) { store.save(any()) }
verify(exactly = 0) { submitter.submit(any()) }
}
@Test
fun `submit does nothing when the email is blank`() = runTest(testDispatcher) {
val vm = viewModel()
vm.updateComment(VALID_COMMENT)
vm.submit()
verify(exactly = 0) { store.save(any()) }
verify(exactly = 0) { submitter.submit(any()) }
}
@Test
fun `submit does nothing when the email is malformed`() = runTest(testDispatcher) {
val vm = viewModel()
vm.updateComment(VALID_COMMENT)
vm.updateEmail("not-an-email")
vm.submit()
verify(exactly = 0) { store.save(any()) }
verify(exactly = 0) { submitter.submit(any()) }
}
@@ -121,4 +174,46 @@ class ReportReviewViewModelTest {
verify(exactly = 1) { store.delete("rid") }
verify(exactly = 0) { submitter.submit(any()) }
}
@Test
fun `isCommentLongEnough is false below the threshold and true at or above it`() {
val short = ReportReviewState(comment = "a".repeat(ReportSubmissionRules.MIN_COMMENT_LENGTH - 1))
val exact = ReportReviewState(comment = "a".repeat(ReportSubmissionRules.MIN_COMMENT_LENGTH))
assertFalse(short.isCommentLongEnough)
assertTrue(exact.isCommentLongEnough)
}
@Test
fun `isEmailValid accepts plausible addresses and rejects malformed or blank input`() {
assertTrue(ReportReviewState(email = "user@example.com").isEmailValid)
assertTrue(ReportReviewState(email = "first.last+tag@sub.example.co.uk").isEmailValid)
assertFalse(ReportReviewState(email = "").isEmailValid)
assertFalse(ReportReviewState(email = "no-at-sign.com").isEmailValid)
assertFalse(ReportReviewState(email = "user@").isEmailValid)
assertFalse(ReportReviewState(email = "user@nodot").isEmailValid)
assertFalse(ReportReviewState(email = "@example.com").isEmailValid)
assertFalse(ReportReviewState(email = "has space@example.com").isEmailValid)
}
@Test
fun `canSubmit requires both a long-enough comment and a valid email`() {
val validComment = "a".repeat(ReportSubmissionRules.MIN_COMMENT_LENGTH)
assertTrue(ReportReviewState(comment = validComment, email = "user@example.com").canSubmit)
assertFalse(ReportReviewState(comment = "short", email = "user@example.com").canSubmit)
assertFalse(ReportReviewState(comment = validComment, email = "not-an-email").canSubmit)
assertFalse(
ReportReviewState(
comment = validComment,
email = "user@example.com",
submit = SubmitUiState.SUBMITTING,
).canSubmit,
)
}
private companion object {
val VALID_COMMENT = "a".repeat(ReportSubmissionRules.MIN_COMMENT_LENGTH)
const val VALID_EMAIL = "reporter@example.com"
}
}