From 763bb915f48d0ac29268689a75e20dba02bc45a6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 19:51:11 +0200 Subject: [PATCH] refactor(crash): draw crash capture + report UI from floret-kit core-crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the app-local crash machinery (CrashReporter, CrashReportDialog, CrashReportSubmit) with floret-kit's core-crash. The kit version is the same privacy-respecting, on-device, no-network design (clipboard + prefilled issue URL via ACTION_VIEW), parameterised by a CrashConfig: app label + the issue-tracker URLs, supplied once at install() in CalendulaApp. - CrashReportActivity stays app-local (thin standalone surface) but now imports the kit's CrashReporter/CrashReportDialog/submitCrashReport. - buildCrashReport moved to the kit and gained an appLabel parameter; the privacy-allowlist test is repointed to the kit API (still pins Calendula's exact six-line header — no identifiers, no calendar content). - Calendula keeps its own crash strings (incl. German + "calendar content" wording) as resource overrides, converted to the kit's %1$s app-label / two-arg body-template arity so the shared code formats them correctly. Compile + unit tests + lintDebug green. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 1 + .../jeanlucmakiola/calendula/CalendulaApp.kt | 17 +- .../jeanlucmakiola/calendula/MainActivity.kt | 6 +- .../calendula/data/crash/CrashReporter.kt | 188 ------------------ .../calendula/ui/crash/CrashReportActivity.kt | 4 +- .../calendula/ui/crash/CrashReportDialog.kt | 75 ------- .../calendula/ui/crash/CrashReportSubmit.kt | 61 ------ .../calendula/ui/settings/SettingsScreen.kt | 8 +- app/src/main/res/values-de/strings.xml | 8 +- app/src/main/res/values/strings.xml | 8 +- .../data/crash/CrashReportBuilderTest.kt | 15 +- 11 files changed, 45 insertions(+), 346 deletions(-) delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/crash/CrashReporter.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportDialog.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c97368..361bd54 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -161,6 +161,7 @@ dependencies { implementation(libs.kotlinx.datetime) implementation("de.jeanlucmakiola.floret:core-time") implementation("de.jeanlucmakiola.floret:core-locale") + implementation("de.jeanlucmakiola.floret:core-crash") implementation(libs.kotlinx.coroutines.core) debugImplementation(libs.androidx.ui.tooling) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index ac300d1..19c63c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -5,7 +5,8 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.backup.BackupWorker -import de.jeanlucmakiola.calendula.data.crash.CrashReporter +import de.jeanlucmakiola.floret.crash.CrashConfig +import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -22,8 +23,18 @@ class CalendulaApp : Application() { override fun onCreate() { super.onCreate() // Install first thing so startup crashes are captured too (privacy- - // respecting, on-device; the user submits the report by hand). - CrashReporter.install(this) + // respecting, on-device; the user submits the report by hand). The + // capture/loop-detection/report machinery lives in floret-kit's + // core-crash; only the app label + issue-tracker URLs are app-specific. + CrashReporter.install( + this, + CrashConfig( + appLabel = getString(R.string.app_name), + newIssueUrl = getString(R.string.report_issue_url), + chooseIssueUrl = getString(R.string.report_issue_choose_url), + issueTitle = getString(R.string.crash_report_issue_title), + ), + ) reconcileAutoBackup() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 840f09b..47ce76e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -21,7 +21,6 @@ import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint -import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.ui.RootScreen @@ -30,9 +29,10 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity -import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog -import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel +import de.jeanlucmakiola.floret.crash.CrashReportDialog +import de.jeanlucmakiola.floret.crash.CrashReporter +import de.jeanlucmakiola.floret.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme import kotlinx.datetime.LocalDate diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/crash/CrashReporter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/crash/CrashReporter.kt deleted file mode 100644 index 93384f0..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/crash/CrashReporter.kt +++ /dev/null @@ -1,188 +0,0 @@ -package de.jeanlucmakiola.calendula.data.crash - -import android.content.Context -import android.content.pm.PackageInfo -import android.os.Build -import androidx.core.content.pm.PackageInfoCompat -import java.io.File -import java.io.PrintWriter -import java.io.StringWriter -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Locale - -/** - * Privacy-respecting crash capture (prod-readiness item 10). On an uncaught - * exception it writes a self-contained report to the app's private storage and - * then chains to the platform's default handler, so the process still dies - * normally (and the OS shows its own "stopped" dialog). Nothing is uploaded — - * the app holds no `INTERNET` permission. The user submits the report later, - * by hand, as a Gitea issue (see the ui/crash surfaces). - * - * The report is built from a fixed [CrashContext] allowlist — app/Android/device - * version, locale, time, and the stack trace — and **nothing else**: no device - * identifiers, no account names, no calendar/event content, no logcat. The user - * is always shown the full text before it leaves the device. - */ -object CrashReporter { - - /** - * Install the handler. Called first thing in `CalendulaApp.onCreate()` so it - * also catches crashes during startup. The handler swallows nothing — it - * persists, then delegates to the previously-registered handler. - */ - fun install(context: Context) { - val appContext = context.applicationContext - val previous = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - // Capturing must never mask the original crash, so guard every step. - runCatching { - val now = System.currentTimeMillis() - writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now)) - recordCrashTime(appContext, now) - } - previous?.uncaughtException(thread, throwable) - } - } - - /** The persisted report from the last crash, or null if there is none. */ - fun pendingReport(context: Context): String? { - val file = reportFile(context) - return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null - } - - /** - * Whether to surface the report unprompted (on the next launch): a report - * exists and the user hasn't already waved this one away. Settings reaches - * the report via [pendingReport] regardless, so "Not now" only stops the - * auto-prompt — it doesn't discard the report. - */ - fun shouldPrompt(context: Context): Boolean = - reportFile(context).exists() && !dismissedFile(context).exists() - - /** Stop auto-prompting for the current report without discarding it. */ - fun dismissPrompt(context: Context) { - runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") } - } - - /** Drop the persisted report once the user has reported it (or from Settings). */ - fun clearReport(context: Context) { - runCatching { reportFile(context).delete() } - runCatching { dismissedFile(context).delete() } - } - - /** - * Whether the app appears to be in a startup crash-loop: at least - * [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI - * can't be trusted to start, so the caller routes straight to the standalone - * report screen instead of re-entering the crashing graph. - */ - fun isCrashLoop(context: Context): Boolean { - val times = readCrashTimes(context) - if (times.size < LOOP_THRESHOLD) return false - val recent = times.sortedDescending() - return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS - } - - /** - * Mark the app as having started successfully, resetting the loop counter so - * an ordinary single crash much later never trips loop detection. The - * pending report itself is kept — only the timing trail is cleared. - */ - fun markHealthy(context: Context) { - runCatching { timesFile(context).delete() } - } - - // --- persistence ------------------------------------------------------- - - private fun writeReport(context: Context, report: String) { - val file = reportFile(context).apply { parentFile?.mkdirs() } - file.writeText(report.take(MAX_REPORT_CHARS)) - // A fresh crash should prompt again, even if the previous one was waved away. - runCatching { dismissedFile(context).delete() } - } - - private fun recordCrashTime(context: Context, nowMillis: Long) { - val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES) - timesFile(context).apply { parentFile?.mkdirs() } - .writeText(kept.joinToString("\n")) - } - - private fun readCrashTimes(context: Context): List { - val file = timesFile(context) - if (!file.exists()) return emptyList() - return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList()) - } - - private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR) - private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE) - private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE) - private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE) - - private const val CRASH_DIR = "crash" - private const val REPORT_FILE = "last_crash.txt" - private const val TIMES_FILE = "crash_times.txt" - private const val DISMISSED_FILE = "dismissed" - private const val MAX_TIMES = 5 - private const val MAX_REPORT_CHARS = 64 * 1024 - private const val LOOP_THRESHOLD = 2 - private const val LOOP_WINDOW_MS = 10_000L -} - -/** - * The allowlist of non-personal facts that go into a crash report. Built from - * [Build] and the app's own [PackageInfo]; deliberately holds no identifiers. - */ -data class CrashContext( - val appVersionName: String, - val appVersionCode: Long, - val sdkInt: Int, - val androidRelease: String, - val manufacturer: String, - val model: String, - val locale: String, -) { - companion object { - fun from(context: Context): CrashContext { - val pkg = runCatching { - context.packageManager.getPackageInfo(context.packageName, 0) - }.getOrNull() - return CrashContext( - appVersionName = pkg?.versionName ?: "?", - appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L, - sdkInt = Build.VERSION.SDK_INT, - androidRelease = Build.VERSION.RELEASE ?: "?", - manufacturer = Build.MANUFACTURER ?: "?", - model = Build.MODEL ?: "?", - locale = Locale.getDefault().toLanguageTag(), - ) - } - } -} - -/** - * Render a crash report from the [ctx] allowlist, the [throwable]'s full stack - * trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit - * tested. The leading marker doubles as the file's sanity check in - * [CrashReporter.pendingReport]. - */ -fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String { - val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim() - val time = runCatching { - Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT) - }.getOrDefault(nowMillis.toString()) - return buildString { - appendLine("Calendula crash report") - appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})") - appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})") - appendLine("Device: ${ctx.manufacturer} ${ctx.model}") - appendLine("Locale: ${ctx.locale}") - appendLine("Time: $time") - appendLine() - appendLine("Stack trace:") - append(trace) - } -} - -private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportActivity.kt index c4501b8..8e3726d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportActivity.kt @@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme +import de.jeanlucmakiola.floret.crash.CrashReportDialog +import de.jeanlucmakiola.floret.crash.CrashReporter +import de.jeanlucmakiola.floret.crash.submitCrashReport /** * A deliberately minimal, standalone surface for a captured crash report. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportDialog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportDialog.kt deleted file mode 100644 index 5fdcbdc..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportDialog.kt +++ /dev/null @@ -1,75 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.crash - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import de.jeanlucmakiola.calendula.R - -/** - * Asks the user to send a captured crash report as an issue. The full report is - * shown verbatim in a scrollable panel — the user sees exactly what will leave - * the device before choosing to share it (the privacy backstop). [onSend] hands - * off to [submitCrashReport]; [onDismiss] declines. - */ -@Composable -fun CrashReportDialog( - report: String, - onSend: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { Icon(Icons.Default.BugReport, contentDescription = null) }, - title = { Text(stringResource(R.string.crash_dialog_title)) }, - text = { - Column { - Text( - text = stringResource(R.string.crash_dialog_message), - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(Modifier.height(12.dp)) - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = report, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .heightIn(max = 220.dp) - .verticalScroll(rememberScrollState()) - .padding(12.dp), - ) - } - } - }, - confirmButton = { - TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) } - }, - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt deleted file mode 100644 index 1477beb..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt +++ /dev/null @@ -1,61 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.crash - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.widget.Toast -import androidx.core.net.toUri -import de.jeanlucmakiola.calendula.R - -/** - * Hand the captured crash report off to the user's chosen channel: the report - * is copied to the clipboard (the reliable path for a full stack trace) and the - * project's Gitea "new issue" page is opened with the body prefilled. Nothing is - * sent automatically — the app has no network access; the user reviews and - * submits the issue themselves. - */ -fun submitCrashReport(context: Context, report: String) { - copyReportToClipboard(context, report) - val opened = runCatching { - context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report))) - }.isSuccess - val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed - Toast.makeText(context, message, Toast.LENGTH_LONG).show() -} - -/** Open the issue tracker's template chooser for a manual (non-crash) report. */ -fun openIssueTracker(context: Context) { - val uri = context.getString(R.string.report_issue_choose_url).toUri() - runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) } -} - -private fun copyReportToClipboard(context: Context, report: String) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return - val label = context.getString(R.string.crash_report_clip_label) - clipboard.setPrimaryClip(ClipData.newPlainText(label, report)) -} - -/** - * The Gitea `issues/new` URL with `title` and `body` prefilled. A full report - * can blow past URL-length limits, so an over-long one is left out of the link - * (with a "paste from clipboard" placeholder) — the clipboard copy is the - * source of truth in that case. - */ -private fun buildIssueUri(context: Context, report: String) = - context.getString(R.string.report_issue_url).toUri().buildUpon() - .appendQueryParameter("title", context.getString(R.string.crash_report_issue_title)) - .appendQueryParameter("body", buildIssueBody(context, report)) - .build() - -private fun buildIssueBody(context: Context, report: String): String { - val block = if (report.length > MAX_URL_REPORT_CHARS) { - context.getString(R.string.crash_report_body_paste) - } else { - "```\n$report\n```" - } - return context.getString(R.string.crash_report_body_template, block) -} - -/** Keep the prefilled body comfortably under common URL-length ceilings. */ -private const val MAX_URL_REPORT_CHARS = 6_000 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 83f52cf..45537b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -84,17 +84,17 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.qs.NewEventTileService -import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog -import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker -import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit +import de.jeanlucmakiola.floret.crash.CrashReportDialog +import de.jeanlucmakiola.floret.crash.CrashReporter +import de.jeanlucmakiola.floret.crash.openIssueTracker +import de.jeanlucmakiola.floret.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 348ba61..acab1b9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -399,14 +399,14 @@ - Calendula ist abgestürzt - Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten. + %1$s ist abgestürzt + %1$s wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten. Melden Nicht jetzt Absturzbericht - Calendula-Absturzbericht + %1$s-Absturzbericht Bericht in die Zwischenablage kopiert Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage. - Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n + Danke, dass du einen Absturz in %1$s meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%2$s\n _(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf0b417..2d55635 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -442,15 +442,15 @@ - Calendula crashed - Calendula closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below. + %1$s crashed + %1$s closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below. Report Not now Crash report - Calendula crash report + %1$s crash report Report copied to your clipboard Couldn\'t open the issue tracker. The report is on your clipboard. - Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n + Thanks for reporting a crash in %1$s. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%2$s\n _(The report was too long for this link — paste it from your clipboard here.)_ https://codeberg.org/jlmakiola/calendula/issues/new https://codeberg.org/jlmakiola/calendula/issues/new/choose diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/crash/CrashReportBuilderTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/crash/CrashReportBuilderTest.kt index a3099b3..c71b429 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/crash/CrashReportBuilderTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/crash/CrashReportBuilderTest.kt @@ -1,8 +1,17 @@ package de.jeanlucmakiola.calendula.data.crash import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.floret.crash.CrashContext +import de.jeanlucmakiola.floret.crash.buildCrashReport import org.junit.jupiter.api.Test +/** + * Guards the privacy contract of the report Calendula ships: the report builder + * itself now lives in floret-kit's core-crash, but the allowlist — exactly the + * app/Android/device/locale/time header lines plus the stack trace, and nothing + * else — is what Calendula relies on, so it stays pinned here with Calendula's + * own app label. + */ class CrashReportBuilderTest { private val context = CrashContext( @@ -17,7 +26,7 @@ class CrashReportBuilderTest { @Test fun `report carries the allowlisted facts and the stack trace`() { - val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L) + val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L, appLabel = "Calendula") assertThat(report).startsWith("Calendula crash report") assertThat(report).contains("App version: 2.7.0 (20700)") @@ -33,7 +42,7 @@ class CrashReportBuilderTest { @Test fun `nested causes are included`() { val cause = NullPointerException("inner") - val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L) + val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L, appLabel = "Calendula") assertThat(report).contains("outer") assertThat(report).contains("Caused by") @@ -42,7 +51,7 @@ class CrashReportBuilderTest { @Test fun `report holds only the allowlisted lines before the stack trace`() { - val report = buildCrashReport(context, Exception("x"), nowMillis = 0L) + val report = buildCrashReport(context, Exception("x"), nowMillis = 0L, appLabel = "Calendula") val header = report.substringBefore("Stack trace:").trim().lines() // No identifiers, accounts, or extra fields ever creep into the header: