diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..2500323 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,23 @@ +--- +name: Bug report +about: Something doesn't work the way it should +title: "" +labels: + - bug +--- + +### What happened + + +### What you expected + + +### Steps to reproduce +1. +2. +3. + +### Environment +- Calendula version: +- Android version: +- Device: diff --git a/.gitea/ISSUE_TEMPLATE/crash_report.md b/.gitea/ISSUE_TEMPLATE/crash_report.md new file mode 100644 index 0000000..c76b1b7 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/crash_report.md @@ -0,0 +1,26 @@ +--- +name: Crash report +about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form. +title: "Crash: " +labels: + - bug + - crash +--- + + + +### What happened + + +### Crash report + +``` +(paste the crash report here) +``` diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..87643af --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,16 @@ +--- +name: Feature request +about: Suggest an idea or improvement +title: "" +labels: + - enhancement +--- + +### What would you like Calendula to do? + + +### Why — what problem does it solve? + + +### Anything else + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 772da76..c37243e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,15 @@ android:resource="@xml/shortcuts" /> + + + diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index 3be4be8..2ec6c24 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -2,10 +2,19 @@ package de.jeanlucmakiola.calendula import android.app.Application import dagger.hilt.android.HiltAndroidApp +import de.jeanlucmakiola.calendula.data.crash.CrashReporter /** * Application entry point. Registered as android:name=".CalendulaApp" * in AndroidManifest.xml. Hilt initializes its component graph here. */ @HiltAndroidApp -class CalendulaApp : Application() +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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 3d53626..f0a857a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -18,9 +18,13 @@ 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.ui.RootScreen import de.jeanlucmakiola.calendula.ui.WidgetNavRequest +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.calendula.ui.theme.CalendulaTheme import kotlinx.datetime.LocalDate @@ -41,12 +45,31 @@ class MainActivity : AppCompatActivity() { // by CalendarHost's import flow. private var requestedImportUri by mutableStateOf(null) + // A captured crash report awaiting the user's decision, surfaced as a dialog + // over the calendar on the next launch (the single-crash path). A startup + // crash-loop is handled out of band, before setContent — see below. + private var pendingCrashReport by mutableStateOf(null) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // If the app keeps crashing as it starts, the main UI can't be trusted + // to come up — route to the standalone report screen instead of + // re-entering the crashing graph. + if (CrashReporter.isCrashLoop(this)) { + startActivity( + Intent(this, CrashReportActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK), + ) + finish() + return + } + enableEdgeToEdge() requestedDetailKey = intent.detailKeyOrNull() requestedNav = intent.navRequestOrNull() requestedImportUri = intent.importUriOrNull() + if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) setContent { // One activity-scoped SettingsViewModel drives both the theme here // and the Settings screen, so a theme change applies app-wide at once. @@ -70,10 +93,32 @@ class MainActivity : AppCompatActivity() { requestedImportUri = requestedImportUri, onImportConsumed = { requestedImportUri = null }, ) + pendingCrashReport?.let { report -> + CrashReportDialog( + report = report, + onSend = { + submitCrashReport(this@MainActivity, report) + CrashReporter.clearReport(this@MainActivity) + pendingCrashReport = null + }, + onDismiss = { + // Keep the report (Settings can still reach it); just + // stop it popping on every launch. + CrashReporter.dismissPrompt(this@MainActivity) + pendingCrashReport = null + }, + ) + } } } } + override fun onResume() { + super.onResume() + // Reaching a running UI means startup succeeded; reset the loop trail. + CrashReporter.markHealthy(this) + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) intent.detailKeyOrNull()?.let { requestedDetailKey = it } 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 new file mode 100644 index 0000000..93384f0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/crash/CrashReporter.kt @@ -0,0 +1,188 @@ +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 new file mode 100644 index 0000000..c4501b8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportActivity.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.calendula.ui.crash + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +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 + +/** + * A deliberately minimal, standalone surface for a captured crash report. + * `MainActivity` routes here when it detects a startup crash-loop (see + * [CrashReporter.isCrashLoop]): the main UI can't be trusted to start, so this + * screen stays clear of the app's Hilt graph, DataStore-backed theme and + * Compose content — it only reads the report file and shows the report dialog. + * Plain [CalendulaTheme] defaults (follow-system, dynamic colour) avoid touching + * anything that might be the cause of the crash. + */ +class CrashReportActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val report = CrashReporter.pendingReport(this) + if (report == null) { + finish() + return + } + enableEdgeToEdge() + setContent { + CalendulaTheme { + // Opaque backdrop so the dialog doesn't float over a bare task. + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} + CrashReportDialog( + report = report, + onSend = { + submitCrashReport(this, report) + CrashReporter.clearReport(this) + finish() + }, + onDismiss = { + CrashReporter.clearReport(this) + finish() + }, + ) + } + } + } + + override fun onResume() { + super.onResume() + // Reaching this screen breaks the loop; reset the timing trail so a + // later ordinary crash isn't mistaken for a loop. + CrashReporter.markHealthy(this) + } +} 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 new file mode 100644 index 0000000..5fdcbdc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportDialog.kt @@ -0,0 +1,75 @@ +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 new file mode 100644 index 0000000..1477beb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt @@ -0,0 +1,61 @@ +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 ac3bef3..4d1a06f 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 @@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ExpandLess @@ -73,10 +74,14 @@ 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.WeekStartPref import de.jeanlucmakiola.calendula.domain.EventFormField +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.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip @@ -196,12 +201,48 @@ private fun SettingsHub( leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) }, onClick = onManageCalendars, ) - LanguageRow(position = Position.Bottom) + LanguageRow(position = Position.Middle) + ReportProblemRow(position = Position.Bottom) AppVersionText() } } +/** + * Opens the project's issue tracker to report a problem. If a crash report was + * captured (and not yet sent), it surfaces that report first via the same + * dialog the next-launch prompt uses; otherwise it opens the issue template + * chooser. No data leaves the device until the user submits the issue. + */ +@Composable +private fun ReportProblemRow(position: Position) { + val context = LocalContext.current + var report by remember { mutableStateOf(null) } + + GroupedRow( + title = stringResource(R.string.settings_report_problem), + summary = stringResource(R.string.settings_report_problem_hint), + position = position, + leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) }, + onClick = { + val pending = CrashReporter.pendingReport(context) + if (pending != null) report = pending else openIssueTracker(context) + }, + ) + + report?.let { pending -> + CrashReportDialog( + report = pending, + onSend = { + submitCrashReport(context, pending) + CrashReporter.clearReport(context) + report = null + }, + onDismiss = { report = null }, + ) + } +} + @Composable private fun LanguageRow(position: Position) { val context = LocalContext.current diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c10888e..4ec4c11 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -282,6 +282,8 @@ Quellcode Version %1$s Calendula-App-Symbol + Problem melden + Absturzbericht senden oder Issue-Tracker öffnen Kalender @@ -337,4 +339,16 @@ %d bereits in diesem Kalender übersprungen. %d bereits in diesem Kalender übersprungen. + + + 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. + Melden + Nicht jetzt + Absturzbericht + Calendula-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 + _(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 e4c7ab1..7801091 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -279,6 +279,8 @@ Source Version %1$s Calendula app icon + Report a problem + Send a crash report or open the issue tracker Calendars @@ -340,4 +342,19 @@ https://gitea.jeanlucmakiola.de/makiolaj/calendula https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE + + + 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. + Report + Not now + Crash report + Calendula 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 + _(The report was too long for this link — paste it from your clipboard here.)_ + https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new + https://gitea.jeanlucmakiola.de/makiolaj/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 new file mode 100644 index 0000000..a3099b3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/crash/CrashReportBuilderTest.kt @@ -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() + } +}