Merge main into feat-163-default-account
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -333,7 +333,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>
|
||||
|
||||
Reference in New Issue
Block a user