core-crash: shared on-device crash capture + report dialog

First Android-library module in the kit. Privacy-respecting crash capture
(writes a self-contained, allowlist-only report to private storage, chains to
the platform handler, uploads nothing), startup crash-loop detection, a report
dialog showing the full text before it leaves the device, and an issue-tracker
hand-off. Extracted from Calendula and generalised: app label + issue URLs come
from a CrashConfig supplied at install(); each app keeps its own thin themed
CrashReportActivity. Lean deps (Compose + core-ktx). 3 unit tests on the pure
report builder.

Adds AGP/android-library + compose plugins and the compose/core-ktx libs to the
kit catalog; core-time stays a plain JVM module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 12:15:41 +02:00
parent 554d536b2f
commit 19c2f8a677
10 changed files with 487 additions and 1 deletions

View File

@@ -0,0 +1,58 @@
// core-crash — privacy-respecting, on-device crash capture + a report dialog and
// issue-tracker hand-off, shared across the family. The reusable machinery lives
// here; each app keeps a thin themed CrashReportActivity and supplies a
// [CrashConfig] (app label + issue-tracker URLs) at install time.
//
// First Android-library module in the kit. Deliberately lean: Compose + core-ktx
// only, no buildConfig, no extra-icons dependency.
plugins {
// AGP 9.x provides built-in Kotlin compilation, so only the Compose plugin is
// applied alongside the Android library plugin (matches the apps' setup).
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "de.jeanlucmakiola.floret.crash"
compileSdk = 37
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
testOptions {
unitTests {
all { it.useJUnitPlatform() }
isReturnDefaultValues = true
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.foundation)
implementation(libs.androidx.material3)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.truth)
}

View File

@@ -0,0 +1,21 @@
package de.jeanlucmakiola.floret.crash
/**
* The per-app facts the shared crash machinery needs: the app's display name
* (for the report header and labels) and its issue-tracker URLs. Supplied once
* at [CrashReporter.install]; ambient thereafter, so the dialog and submit flow
* read it without the caller threading it through.
*
* URLs are passed as plain strings (resolved from each app's own resources)
* rather than kit resources, since they differ per app and are not translated.
*/
data class CrashConfig(
/** Display name, e.g. "Agendula". Used in the report header and dialog copy. */
val appLabel: String,
/** Issue tracker "new issue" endpoint; the crash flow appends title + body. */
val newIssueUrl: String,
/** Issue tracker entry point for a manual (non-crash) report — e.g. a chooser. */
val chooseIssueUrl: String,
/** Title used for the prefilled crash issue, e.g. "Crash report". */
val issueTitle: String,
)

View File

@@ -0,0 +1,72 @@
package de.jeanlucmakiola.floret.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.material3.AlertDialog
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
/**
* 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. The app's display name is
* read from the installed [CrashReporter.config].
*/
@Composable
fun CrashReportDialog(
report: String,
onSend: () -> Unit,
onDismiss: () -> Unit,
) {
val appLabel = CrashReporter.config?.appLabel ?: stringResource(R.string.crash_app_fallback)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.crash_dialog_title, appLabel)) },
text = {
Column {
Text(
text = stringResource(R.string.crash_dialog_message, appLabel),
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)) }
},
)
}

View File

@@ -0,0 +1,62 @@
package de.jeanlucmakiola.floret.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
/**
* 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 "new issue" page is opened with the body prefilled. Nothing is sent
* automatically — the apps have no network access; the user reviews and submits
* the issue themselves. Issue-tracker URLs and the app label come from the
* installed [CrashReporter.config].
*/
fun submitCrashReport(context: Context, report: String) {
val config = CrashReporter.config ?: return
copyReportToClipboard(context, config.appLabel, report)
val opened = runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, config, 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 entry point for a manual (non-crash) report. */
fun openIssueTracker(context: Context) {
val url = CrashReporter.config?.chooseIssueUrl ?: return
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
}
private fun copyReportToClipboard(context: Context, appLabel: String, report: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
val label = context.getString(R.string.crash_report_clip_label, appLabel)
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
}
/**
* The `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, config: CrashConfig, report: String) =
config.newIssueUrl.toUri().buildUpon()
.appendQueryParameter("title", config.issueTitle)
.appendQueryParameter("body", buildIssueBody(context, config.appLabel, report))
.build()
private fun buildIssueBody(context: Context, appLabel: String, 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, appLabel, block)
}
/** Keep the prefilled body comfortably under common URL-length ceilings. */
private const val MAX_URL_REPORT_CHARS = 6_000

View File

@@ -0,0 +1,197 @@
package de.jeanlucmakiola.floret.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, shared across the Floret family. 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 apps hold no `INTERNET` permission. The user submits the report
* later, by hand, as an issue (see [CrashReportDialog] / [submitCrashReport]).
*
* 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 app content, no logcat. The user is always
* shown the full text before it leaves the device.
*
* [install] takes a [CrashConfig] (app label + issue-tracker URLs); it is stored
* as [config] so the standalone crash surfaces can read it without the app graph.
*/
object CrashReporter {
/** The config from the last [install], or null before the app has installed. */
var config: CrashConfig? = null
private set
/**
* Install the handler. Call first thing in `Application.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, config: CrashConfig) {
this.config = config
val appContext = context.applicationContext
val label = config.appLabel
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, label))
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<Long> {
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, the crash [nowMillis], and the [appLabel] (for the header). 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, appLabel: String): 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("$appLabel 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")

View File

@@ -0,0 +1,19 @@
<resources>
<!--
Generic crash-report UI strings shared by the family. App-agnostic: the
app's display name is injected as %1$s from CrashConfig.appLabel, so these
read correctly for any app. Issue-tracker URLs are NOT here — they are
per-app and come from CrashConfig. Apps may override any of these strings.
-->
<string name="crash_dialog_title">%1$s crashed</string>
<string name="crash_dialog_message">%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 app content — only the technical details below.</string>
<string name="crash_dialog_report">Report</string>
<string name="crash_dialog_dismiss">Not now</string>
<string name="crash_report_clip_label">%1$s crash report</string>
<string name="crash_report_copied">Report copied to your clipboard</string>
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
<string name="crash_report_body_template">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</string>
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
<!-- Fallback app label if the dialog is shown before install() (should not happen). -->
<string name="crash_app_fallback">The app</string>
</resources>

View File

@@ -0,0 +1,41 @@
package de.jeanlucmakiola.floret.crash
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class BuildCrashReportTest {
private val ctx = CrashContext(
appVersionName = "1.2.3",
appVersionCode = 10203,
sdkInt = 34,
androidRelease = "14",
manufacturer = "Acme",
model = "Pixel",
locale = "en-US",
)
@Test
fun `report leads with the app label header`() {
val report = buildCrashReport(ctx, RuntimeException("boom"), 0L, "Agendula")
assertThat(report.lineSequence().first()).isEqualTo("Agendula crash report")
}
@Test
fun `report includes the allowlisted facts and the stack trace`() {
val report = buildCrashReport(ctx, IllegalStateException("kaboom"), 0L, "Calendula")
assertThat(report).contains("App version: 1.2.3 (10203)")
assertThat(report).contains("Android: 14 (API 34)")
assertThat(report).contains("Device: Acme Pixel")
assertThat(report).contains("Locale: en-US")
assertThat(report).contains("Stack trace:")
assertThat(report).contains("kaboom")
}
@Test
fun `the throwable's message and type appear in the trace`() {
val report = buildCrashReport(ctx, IllegalArgumentException("bad arg"), 0L, "App")
assertThat(report).contains("IllegalArgumentException")
assertThat(report).contains("bad arg")
}
}