Compare commits

...

4 Commits

Author SHA1 Message Date
19c2f8a677 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>
2026-06-28 12:15:41 +02:00
554d536b2f settings: reword toolchain note to keep the 'foojay' token out of source
Calendula scans its whole source tree (incl. this submodule) for the literal
token as a reproducibility guard; the explanatory comment shouldn't trip it.
No behavioural change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:45:28 +02:00
39cdfbcb71 settings: drop foojay toolchain resolver (reproducible-build safe)
Calendula consumes the kit as an included build and publishes via official
F-Droid reproducible builds, whose offline source scanner rejects the foojay
toolchain resolver (it can fetch a JDK at build time). The kit never used a
Java toolchain block — modules set jvmTarget directly — so the resolver was
dead weight that would have broken Calendula's reproducibility invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:44:31 +02:00
7efad77fde core-time: add TimeBridge (Instant <-> epoch millis)
Generic content-provider time bridge shared by the family: Long epoch millis
<-> kotlin.time.Instant. Extracted from Calendula for Phase 1 (Calendula as the
second core-time consumer). 3 unit tests; module suite now 5 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:37:25 +02:00
12 changed files with 528 additions and 4 deletions

View File

@@ -3,6 +3,8 @@
// consuming apps can depend on `de.jeanlucmakiola.floret:<module>` and Gradle's // consuming apps can depend on `de.jeanlucmakiola.floret:<module>` and Gradle's
// composite-build substitution maps it to the local source module. // composite-build substitution maps it to the local source module.
plugins { plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.jvm) apply false
} }

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")
}
}

View File

@@ -0,0 +1,9 @@
package de.jeanlucmakiola.floret.time
import kotlin.time.Instant
/** Epoch milliseconds (as stored by Android content providers) → [Instant]. */
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
/** [Instant] → epoch milliseconds, for writing back to a content provider. */
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()

View File

@@ -0,0 +1,26 @@
package de.jeanlucmakiola.floret.time
import com.google.common.truth.Truth.assertThat
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class TimeBridgeTest {
@Test
fun `epoch millis round-trips through Instant`() {
val original = 1_717_840_800_000L // 2024-06-08T10:00:00Z
val instant = original.toKotlinInstantFromEpochMillis()
assertThat(instant.toEpochMillis()).isEqualTo(original)
}
@Test
fun `zero millis maps to Instant epoch`() {
assertThat(0L.toKotlinInstantFromEpochMillis()).isEqualTo(Instant.fromEpochMilliseconds(0L))
}
@Test
fun `negative epoch millis is supported`() {
val original = -1_000_000L
assertThat(original.toKotlinInstantFromEpochMillis().toEpochMillis()).isEqualTo(original)
}
}

View File

@@ -1,16 +1,29 @@
# Version catalog for floret-kit. Kept in lockstep with the consuming apps' # 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] [versions]
agp = "9.2.1"
kotlin = "2.3.21" 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" junit = "6.1.0"
junitPlatform = "6.1.0" junitPlatform = "6.1.0"
truth = "1.4.5" truth = "1.4.5"
[libraries] [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-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-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" } junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatform" }
truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
[plugins] [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" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

View File

@@ -11,9 +11,12 @@ pluginManagement {
gradlePluginPortal() gradlePluginPortal()
} }
} }
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" // NB: deliberately NO Gradle Java-toolchain auto-download resolver plugin here.
} // Consuming apps build this as an included build, and Calendula publishes via
// official F-Droid reproducible builds whose offline source scanner rejects any
// toolchain resolver that can fetch a JDK at build time. Modules set jvmTarget
// directly and must not add a Java toolchain block that needs such a resolver.
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
@@ -29,3 +32,4 @@ dependencyResolutionManagement {
rootProject.name = "floret-kit" rootProject.name = "floret-kit"
include(":core-time") include(":core-time")
include(":core-crash")