@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class) import gobley.gradle.cargo.dsl.jvm import gobley.gradle.cargo.tasks.CargoBuildTask import gobley.gradle.cargo.tasks.CargoCheckTask import gobley.gradle.GobleyHost import gobley.gradle.rust.targets.RustAndroidTarget import gobley.gradle.rust.targets.RustTarget import gobley.gradle.Variant import org.gradle.api.DefaultTask import org.gradle.api.provider.ListProperty import org.gradle.api.tasks.Input import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.jvm.tasks.Jar import org.jetbrains.kotlin.gradle.dsl.JvmTarget abstract class VerifyHostCargoTaskSelection : DefaultTask() { @get:Input abstract val mismatches: ListProperty @TaskAction fun verify() { check(mismatches.get().isEmpty()) { "Cargo tasks do not match the current host: ${mismatches.get().joinToString()}" } } } plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidLibrary) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.gobleyCargo) alias(libs.plugins.gobleyUniffi) alias(libs.plugins.kotlinAtomicfu) } val appVersion = rootProject.extra["vnidrop.productVersion"] as String val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant") .map { value -> when (value.trim().lowercase()) { "debug" -> Variant.Debug "release" -> Variant.Release else -> error("vnidrop.desktop.rustVariant must be either debug or release") } } .orElse(Variant.Debug) // Compile-time switches (gradle.properties or -P…). // included=false: user-initiated bug reports use a NoOp transport (never sent). // endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed). val diagnosticsIncluded: Boolean = (findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false val diagnosticsEndpoint: String = (findProperty("vnidrop.diagnostics.endpoint") as String?)?.trim().orEmpty() val diagnosticsIngestKey: String = (findProperty("vnidrop.diagnostics.ingestKey") as String?)?.trim().orEmpty() check(!diagnosticsIncluded || diagnosticsEndpoint.isEmpty() == diagnosticsIngestKey.isEmpty()) { "vnidrop.diagnostics.endpoint and vnidrop.diagnostics.ingestKey must be configured together" } val diagnosticsBuildConfigDir = layout.buildDirectory.dir("generated/diagnostics/commonMain/kotlin") val generateDiagnosticsBuildConfig by tasks.registering { group = "build" description = "Generates DiagnosticsBuildConfig from vnidrop.diagnostics.* properties" val outputDir = diagnosticsBuildConfigDir val included = diagnosticsIncluded val endpoint = if (included) diagnosticsEndpoint else "" val ingestKey = if (included) diagnosticsIngestKey else "" inputs.property("vnidrop.diagnostics.included", included) inputs.property("vnidrop.diagnostics.endpoint", endpoint) inputs.property("vnidrop.diagnostics.ingestKey", ingestKey) outputs.dir(outputDir) doLast { val packageDir = outputDir.get().asFile.resolve("com/vnidrop/app/diagnostics") packageDir.mkdirs() fun esc(value: String): String = buildString { for (ch in value) { when (ch) { '\\' -> append("\\\\") '"' -> append("\\\"") '\n' -> append("\\n") '\r' -> append("\\r") '\t' -> append("\\t") '$' -> append("\\$") else -> append(ch) } } } packageDir.resolve("DiagnosticsBuildConfig.kt").writeText( """ |package com.vnidrop.app.diagnostics | |/** | * Generated by shared/build.gradle.kts from vnidrop.diagnostics.* properties. | * | * - included=false → no opt-in UI / telemetry / crash hooks | * - endpoint/key both empty → [NoOpDiagnosticsTransport] (no network) | */ |object DiagnosticsBuildConfig { | const val INCLUDED: Boolean = $included | const val ENDPOINT: String = "${esc(endpoint)}" | const val INGEST_KEY: String = "${esc(ingestKey)}" |} | """.trimMargin(), ) } } // App-wide public constants (privacy policy URL, …) from the shared app.properties, // so Apple and KMP read one source of truth instead of hardcoding values. val appProperties = java.util.Properties().apply { rootProject.file("app.properties").inputStream().use(::load) } val privacyPolicyUrl: String = appProperties.getProperty("PRIVACY_POLICY_URL")?.trim().orEmpty() check(privacyPolicyUrl.isNotEmpty()) { "PRIVACY_POLICY_URL must be set in app.properties" } val appConfigDir = layout.buildDirectory.dir("generated/appconfig/commonMain/kotlin") val generateAppConfig by tasks.registering { group = "build" description = "Generates AppConfig from the shared app.properties" val outputDir = appConfigDir val privacy = privacyPolicyUrl inputs.property("PRIVACY_POLICY_URL", privacy) outputs.dir(outputDir) doLast { val packageDir = outputDir.get().asFile.resolve("com/vnidrop/app") packageDir.mkdirs() fun esc(value: String): String = buildString { for (ch in value) { when (ch) { '\\' -> append("\\\\") '"' -> append("\\\"") '\n' -> append("\\n") '\r' -> append("\\r") '\t' -> append("\\t") '$' -> append("\\$") else -> append(ch) } } } packageDir.resolve("AppConfig.kt").writeText( """ |package com.vnidrop.app | |/** | * Generated by shared/build.gradle.kts from the shared app.properties. | * Single source of truth for app-wide public constants (also used by Apple). | */ |object AppConfig { | const val PRIVACY_POLICY_URL: String = "${esc(privacy)}" |} | """.trimMargin(), ) } } kotlin { androidTarget { compilerOptions { jvmTarget = JvmTarget.JVM_11 } } jvm() sourceSets { commonMain { kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig)) kotlin.srcDir(files(appConfigDir).builtBy(generateAppConfig)) } androidMain.dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.ktx) implementation(libs.google.code.scanner) implementation(libs.compose.uiToolingPreview) } jvmMain.dependencies { implementation(libs.filekit.dialogs) implementation(libs.jna.platform) } commonMain.dependencies { implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.material3) implementation(libs.compose.ui) implementation(libs.compose.components.resources) implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.datastore) implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.coroutinesCore) implementation(libs.qrcode.kotlin) } commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutinesTest) } jvmTest.dependencies { implementation(compose.desktop.currentOs) implementation(libs.compose.uiTest) } } } dependencies { debugImplementation(libs.compose.uiTooling) } android { namespace = "com.vnidrop.app.shared" compileSdk = libs.versions.android.compileSdk.get().toInt() defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } } val hostCargoTargets = buildSet { add(GobleyHost.current.rustTarget) addAll(RustAndroidTarget.entries) } cargo { packageDirectory = layout.projectDirectory.dir("../crates/vnidrop") publishJvmArtifacts = true jvmVariant.set(desktopRustVariant) androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64, RustAndroidTarget.X64)) builds.jvm { variants { // Desktop distributions are built per host. Do not publish disabled // cross-platform native jars into the app runtime classpath. embedRustLibrary.set(rustTarget == GobleyHost.current.rustTarget) } } builds.configureEach { val buildOnCurrentHost = rustTarget in hostCargoTargets installTargetBeforeBuild.set(buildOnCurrentHost) variants { buildTaskProvider.configure { enabled = buildOnCurrentHost } checkTaskProvider.configure { enabled = buildOnCurrentHost } } } } uniffi { generateFromLibrary { namespace = "vnidrop" build.set(GobleyHost.current.rustTarget) variant.set(desktopRustVariant) } } tasks.named("jvmJar") { manifest.attributes["Implementation-Version"] = appVersion } val verifyHostCargoTaskSelection = tasks.register( "verifyHostCargoTaskSelection" ) { group = "verification" description = "Verifies that Cargo tasks are enabled only for targets supported by this host." mismatches.convention(emptyList()) } afterEvaluate { verifyHostCargoTaskSelection.configure { mismatches.set(buildList { tasks.withType().forEach { if (it.enabled != (it.target.get() in hostCargoTargets)) add(it.path) } tasks.withType().forEach { if (it.enabled != (it.target.get() in hostCargoTargets)) add(it.path) } }) } } tasks.named("check") { dependsOn(verifyHostCargoTaskSelection) } tasks.configureEach { // Gobley does not currently treat every Rust source/API change as an input of // all platform cargo tasks. Without these inputs an incremental Android build // can package an older .so next to freshly generated UniFFI Kotlin bindings. if (name.startsWith("cargoBuild")) { inputs.files( fileTree(layout.projectDirectory.dir("../crates/vnidrop")) { include("Cargo.toml", "build.rs", "src/**/*.rs") }, layout.projectDirectory.file("../Cargo.toml"), layout.projectDirectory.file("../Cargo.lock"), ).withPathSensitivity(PathSensitivity.RELATIVE) } }