feat(crash): privacy-respecting crash reporting via Gitea issue
Capture uncaught exceptions on-device and let the user submit them, by hand, as a Gitea issue — no network access, no auto-upload (the app holds no INTERNET permission). Closes prod-readiness item 10; the issue templates also close item 7. - CrashReporter: uncaught-exception handler installed first in CalendulaApp.onCreate so startup crashes are caught too. Persists an allowlist-only report (app/Android/device version, locale, time, stack trace — nothing else) to filesDir/crash, then chains to the previous handler so the process still dies normally. Crash-loop detection + markHealthy reset. - buildCrashReport is pure/testable; CrashReportBuilderTest asserts the header is exactly the allowlisted lines (guards against PII creep). - Surfacing: next-launch dialog showing the full report verbatim (the privacy backstop) with a dismissed-marker so it doesn't nag; a Settings "Report a problem" row; and a minimal standalone CrashReportActivity that MainActivity routes to on a startup crash-loop, kept clear of the Hilt graph / DataStore theme. - submitCrashReport copies the report to the clipboard and opens the prefilled Gitea issues/new URL (long traces fall back to paste). - .gitea/ISSUE_TEMPLATE: crash_report, bug_report, feature_request. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package de.jeanlucmakiola.calendula.data.crash
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CrashReportBuilderTest {
|
||||
|
||||
private val context = CrashContext(
|
||||
appVersionName = "2.7.0",
|
||||
appVersionCode = 20700,
|
||||
sdkInt = 34,
|
||||
androidRelease = "14",
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 7",
|
||||
locale = "en-DE",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `report carries the allowlisted facts and the stack trace`() {
|
||||
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L)
|
||||
|
||||
assertThat(report).startsWith("Calendula crash report")
|
||||
assertThat(report).contains("App version: 2.7.0 (20700)")
|
||||
assertThat(report).contains("Android: 14 (API 34)")
|
||||
assertThat(report).contains("Device: Google Pixel 7")
|
||||
assertThat(report).contains("Locale: en-DE")
|
||||
// The exception type + message and a frame from this test are present.
|
||||
assertThat(report).contains("IllegalStateException")
|
||||
assertThat(report).contains("boom")
|
||||
assertThat(report).contains("CrashReportBuilderTest")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nested causes are included`() {
|
||||
val cause = NullPointerException("inner")
|
||||
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L)
|
||||
|
||||
assertThat(report).contains("outer")
|
||||
assertThat(report).contains("Caused by")
|
||||
assertThat(report).contains("inner")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `report holds only the allowlisted lines before the stack trace`() {
|
||||
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L)
|
||||
val header = report.substringBefore("Stack trace:").trim().lines()
|
||||
|
||||
// No identifiers, accounts, or extra fields ever creep into the header:
|
||||
// it is exactly the six allowlisted lines plus the title.
|
||||
assertThat(header).hasSize(6)
|
||||
assertThat(header.first()).isEqualTo("Calendula crash report")
|
||||
assertThat(header.map { it.substringBefore(":") }).containsExactly(
|
||||
"Calendula crash report", "App version", "Android", "Device", "Locale", "Time",
|
||||
).inOrder()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user