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:
58
core-crash/build.gradle.kts
Normal file
58
core-crash/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
19
core-crash/src/main/res/values/strings.xml
Normal file
19
core-crash/src/main/res/values/strings.xml
Normal 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>
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user