From 19c2f8a6778ba1971c56be4248dc6f6ac98f7dec Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 12:15:41 +0200 Subject: [PATCH] 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) --- build.gradle.kts | 2 + core-crash/build.gradle.kts | 58 ++++++ .../floret/crash/CrashConfig.kt | 21 ++ .../floret/crash/CrashReportDialog.kt | 72 +++++++ .../floret/crash/CrashReportSubmit.kt | 62 ++++++ .../floret/crash/CrashReporter.kt | 197 ++++++++++++++++++ core-crash/src/main/res/values/strings.xml | 19 ++ .../floret/crash/BuildCrashReportTest.kt | 41 ++++ gradle/libs.versions.toml | 15 +- settings.gradle.kts | 1 + 10 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 core-crash/build.gradle.kts create mode 100644 core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashConfig.kt create mode 100644 core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReportDialog.kt create mode 100644 core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReportSubmit.kt create mode 100644 core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReporter.kt create mode 100644 core-crash/src/main/res/values/strings.xml create mode 100644 core-crash/src/test/kotlin/de/jeanlucmakiola/floret/crash/BuildCrashReportTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index e622a4f..bf0fc45 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,8 @@ // consuming apps can depend on `de.jeanlucmakiola.floret:` and Gradle's // composite-build substitution maps it to the local source module. plugins { + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.jvm) apply false } diff --git a/core-crash/build.gradle.kts b/core-crash/build.gradle.kts new file mode 100644 index 0000000..e7722e9 --- /dev/null +++ b/core-crash/build.gradle.kts @@ -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) +} diff --git a/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashConfig.kt b/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashConfig.kt new file mode 100644 index 0000000..0198800 --- /dev/null +++ b/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashConfig.kt @@ -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, +) diff --git a/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReportDialog.kt b/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReportDialog.kt new file mode 100644 index 0000000..3ed3b3f --- /dev/null +++ b/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReportDialog.kt @@ -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)) } + }, + ) +} diff --git a/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReportSubmit.kt b/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReportSubmit.kt new file mode 100644 index 0000000..8775499 --- /dev/null +++ b/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReportSubmit.kt @@ -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 diff --git a/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReporter.kt b/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReporter.kt new file mode 100644 index 0000000..12ad6ce --- /dev/null +++ b/core-crash/src/main/kotlin/de/jeanlucmakiola/floret/crash/CrashReporter.kt @@ -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 { + 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") diff --git a/core-crash/src/main/res/values/strings.xml b/core-crash/src/main/res/values/strings.xml new file mode 100644 index 0000000..a482e74 --- /dev/null +++ b/core-crash/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + %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 app content — only the technical details below. + Report + Not now + %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 %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.)_ + + The app + diff --git a/core-crash/src/test/kotlin/de/jeanlucmakiola/floret/crash/BuildCrashReportTest.kt b/core-crash/src/test/kotlin/de/jeanlucmakiola/floret/crash/BuildCrashReportTest.kt new file mode 100644 index 0000000..1879261 --- /dev/null +++ b/core-crash/src/test/kotlin/de/jeanlucmakiola/floret/crash/BuildCrashReportTest.kt @@ -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") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bc876a3..04e5f08 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,29 @@ # Version catalog for floret-kit. Kept in lockstep with the consuming apps' -# catalogs (same Kotlin + test stack) until the catalog itself is shared. +# catalogs (same AGP/Kotlin/Compose + test stack) until the catalog itself is shared. [versions] +agp = "9.2.1" kotlin = "2.3.21" +coreKtx = "1.19.0" +composeBom = "2026.05.01" +# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM. +material3 = "1.5.0-alpha21" junit = "6.1.0" junitPlatform = "6.1.0" truth = "1.4.5" [libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } + junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatform" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } [plugins] +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 75008f4..20ee9b7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,3 +32,4 @@ dependencyResolutionManagement { rootProject.name = "floret-kit" include(":core-time") +include(":core-crash")