use crate::core::template_versions::{maven, toolchain};
#[expect(
clippy::too_many_arguments,
reason = "template-rendering entry point: every parameter is an independent scalar forwarded once into the Jinja context, so a params struct would exist only to satisfy the argument counter. Revisit if this grows behaviour beyond forwarding. ~keep"
)]
pub(super) fn render_build_gradle_kotlin_android(
kotlin_pkg_id: &str,
maven_coordinate: &str,
dep_mode: crate::e2e::config::DependencyMode,
jni_lib_name: &str,
jni_crate_path: &str,
e2e_env: &std::collections::HashMap<String, String>,
capsule_types: &std::collections::HashMap<String, crate::core::config::HostCapsuleTypeConfig>,
test_documents_path: &str,
) -> String {
let test_env_block = {
let mut keys: Vec<&String> = e2e_env.keys().collect();
keys.sort();
let mut block = String::new();
for key in keys {
let value = &e2e_env[key];
let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
block.push_str(&format!("\n environment(\"{}\", \"{}\")", esc(key), esc(value)));
}
block
};
let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
let android_gradle_plugin = maven::ANDROID_GRADLE_PLUGIN;
let agp_major: u32 = android_gradle_plugin
.split('.')
.next()
.and_then(|major| major.parse().ok())
.unwrap_or(0);
let kotlin_android_plugin_line = if agp_major >= 9 {
String::new()
} else {
format!("\n kotlin(\"android\") version \"{kotlin_plugin}\"")
};
let junit = maven::JUNIT;
let jackson = maven::JACKSON_E2E;
let jackson_annotations = maven::JACKSON_ANNOTATIONS;
let jvm_target = if junit.starts_with("6.") {
"17"
} else {
toolchain::ANDROID_JVM_TARGET
};
let jna = maven::JNA;
let jspecify = maven::JSPECIFY;
let coroutines = maven::KOTLINX_COROUTINES_CORE;
let launcher_dep = format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#);
let (source_sets_block, artifact_dep) = if dep_mode == crate::e2e::config::DependencyMode::Registry {
let artifact = format!(
r#" // Published Android AAR from Maven Central (verifies artifact resolution)
implementation("{maven_coordinate}")"#
);
(String::new(), artifact)
} else {
let src_sets = r#"
sourceSets {
getByName("test") {
// Include the AAR-bundled Java facade as test sources
java.srcDir("../../packages/kotlin-android/src/main/java")
// Include the AAR-bundled Kotlin wrapper as test sources
kotlin.srcDir("../../packages/kotlin-android/src/main/kotlin")
}
}
"#;
(src_sets.to_string(), String::new())
};
let tasks_block = if dep_mode == crate::e2e::config::DependencyMode::Registry {
format!(
r#"tasks.register("verifyAarPublished") {{
description = "Verify the published Android AAR contains jni and classes.jar"
doLast {{
val aarCoord = "{maven_coordinate}"
val (groupId, artifactId, version) = run {{
val parts = aarCoord.split(':')
Triple(parts[0], parts[1], parts[2])
}}
val aarFileName = "${{artifactId}}-${{version}}.aar"
val mavenUrl = "https://repo1.maven.org/maven2/${{groupId.replace('.', '/')}}/${{artifactId}}/${{version}}/${{aarFileName}}"
val aarFile = layout.buildDirectory.file("tmp/${{aarFileName}}").get().asFile
println("Downloading AAR from Maven Central: ${{mavenUrl}}")
aarFile.parentFile.mkdirs()
val connection = URL(mavenUrl).openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connect()
if (connection.responseCode != 200) {{
throw GradleException("Failed to download AAR: HTTP ${{connection.responseCode}}")
}}
connection.inputStream.use {{ input ->
aarFile.outputStream().use {{ output ->
input.copyTo(output)
}}
}}
println("Verifying AAR contents...")
ZipFile(aarFile).use {{ zip ->
val entries = zip.entries().toList()
val hasJni = entries.any {{ it.name.startsWith("jni/") }}
val hasClasses = entries.any {{ it.name == "classes.jar" }}
if (!hasJni) {{
throw GradleException("AAR missing jni directory")
}}
if (!hasClasses) {{
throw GradleException("AAR missing classes.jar")
}}
val abiDirs = entries
.filter {{ it.name.startsWith("jni/") }}
.map {{ it.name.substringAfter("jni/").substringBefore("/") }}
.filter {{ it.isNotEmpty() }}
.distinct()
println(" + jni: YES")
println(" + classes.jar: YES")
println(" + Android ABIs: " + abiDirs.sorted().joinToString(", "))
println("\nAAR verification PASSED!")
}}
}}
}}
// Build host JNI library for JVM unit tests (macOS/Linux/Windows).
// The generated Kotlin Bridge object calls System.loadLibrary("{jni_lib_name}") for JVM
// unit tests running on developer machines. This task builds the host-platform binary
// and stages it into src/test/resources/host-jni/<platform>/ for the test loader.
// Set alef.skipHostJni=true to disable this (e.g., in CI where only AAR validation is needed).
tasks.register("buildHostJni", Exec::class) {{
if (project.properties["alef.skipHostJni"] != "true") {{
val jniCargoPath = "{jni_crate_path}/Cargo.toml"
description = "Build host-platform JNI library from {jni_crate_path}"
commandLine("cargo", "build", "--release", "--manifest-path", jniCargoPath)
errorOutput = System.err
}} else {{
description = "Build host JNI (disabled via alef.skipHostJni=true)"
commandLine("true")
}}
}}
tasks.register("copyHostJni", Copy::class) {{
if (project.properties["alef.skipHostJni"] != "true") {{
description = "Copy host JNI library to test resources"
dependsOn("buildHostJni")
val hostPlatform = if (System.getProperty("os.name").lowercase().contains("mac")) {{
"darwin"
}} else if (System.getProperty("os.name").lowercase().contains("win")) {{
"windows"
}} else {{
"linux"
}}
val libName = when (hostPlatform) {{
"darwin" -> "lib{jni_lib_name}.dylib"
"windows" -> "{jni_lib_name}.dll"
else -> "lib{jni_lib_name}.so"
}}
// Cargo builds to the workspace target directory by default, even when
// --manifest-path points at a member crate. The previous
// `if (workspaceTarget.exists()) ... else crateTarget` dual-path was
// evaluated at gradle configuration time, before `cargo build` finished
// or before the workspace target dir existed, so the glob could match
// zero files and the test runtime would fail with `UnsatisfiedLinkError`
// at static-init time. Always read from the workspace target.
val workspaceTarget = file("../../target/release")
from(workspaceTarget) {{
include(libName)
}}
into(layout.projectDirectory.dir("src/test/resources/host-jni/$hostPlatform"))
}}
}}
tasks.withType<Test> {{
useJUnitPlatform(){test_env_block}
dependsOn("verifyAarPublished")
if (project.properties["alef.skipHostJni"] != "true") {{
val hostPlatform = if (System.getProperty("os.name").lowercase().contains("mac")) {{
"darwin"
}} else if (System.getProperty("os.name").lowercase().contains("win")) {{
"windows"
}} else {{
"linux"
}}
systemProperty(
"java.library.path",
project.layout.projectDirectory.dir("src/test/resources/host-jni/$hostPlatform").asFile.absolutePath
)
dependsOn("copyHostJni")
}}
}}
tasks.matching {{ it.name.startsWith("processDebug") || it.name.startsWith("processRelease") }}.configureEach {{
if (project.properties["alef.skipHostJni"] != "true" && name.contains("UnitTestJavaRes")) {{
dependsOn("copyHostJni")
}}
}}"#,
maven_coordinate = maven_coordinate,
jni_crate_path = jni_crate_path,
jni_lib_name = jni_lib_name,
test_env_block = test_env_block,
)
} else {
let guarded_working_dir = crate::e2e::template_env::render(
"gradle/guarded_working_dir.kt.jinja",
minijinja::context! { test_documents_path => test_documents_path },
);
format!(
r#"// Build host JNI library for JVM unit tests (macOS/Linux/Windows).
// The generated Kotlin Bridge object calls System.loadLibrary("{jni_lib_name}") for JVM
// unit tests running on developer machines. This task builds the host-platform binary
// and stages it into src/test/resources/host-jni/<platform>/ for the test loader.
// Set alef.skipHostJni=true to disable this (e.g., in CI where only source-set validation is needed).
tasks.register("buildHostJni", Exec::class) {{
if (project.properties["alef.skipHostJni"] != "true") {{
val jniCargoPath = "{jni_crate_path}/Cargo.toml"
description = "Build host-platform JNI library from {jni_crate_path}"
commandLine("cargo", "build", "--release", "--manifest-path", jniCargoPath)
errorOutput = System.err
}} else {{
description = "Build host JNI (disabled via alef.skipHostJni=true)"
commandLine("true")
}}
}}
tasks.register("copyHostJni", Copy::class) {{
if (project.properties["alef.skipHostJni"] != "true") {{
description = "Copy host JNI library to test resources"
dependsOn("buildHostJni")
val hostPlatform = if (System.getProperty("os.name").lowercase().contains("mac")) {{
"darwin"
}} else if (System.getProperty("os.name").lowercase().contains("win")) {{
"windows"
}} else {{
"linux"
}}
val libName = when (hostPlatform) {{
"darwin" -> "lib{jni_lib_name}.dylib"
"windows" -> "{jni_lib_name}.dll"
else -> "lib{jni_lib_name}.so"
}}
// Cargo builds to the workspace target directory by default, even when
// --manifest-path points at a member crate. The previous
// `if (workspaceTarget.exists()) ... else crateTarget` dual-path was
// evaluated at gradle configuration time, before `cargo build` finished
// or before the workspace target dir existed, so the glob could match
// zero files and the test runtime would fail with `UnsatisfiedLinkError`
// at static-init time. Always read from the workspace target.
val workspaceTarget = file("../../target/release")
from(workspaceTarget) {{
include(libName)
}}
into(layout.projectDirectory.dir("src/test/resources/host-jni/$hostPlatform"))
}}
}}
tasks.withType<Test> {{
useJUnitPlatform(){test_env_block}
// Resolve the native library location (e.g., ../../target/release)
val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
systemProperty("jna.library.path", libPath)
{guarded_working_dir}
if (project.properties["alef.skipHostJni"] != "true") {{
val hostPlatform = if (System.getProperty("os.name").lowercase().contains("mac")) {{
"darwin"
}} else if (System.getProperty("os.name").lowercase().contains("win")) {{
"windows"
}} else {{
"linux"
}}
val hostedPath = project.layout.projectDirectory.dir("src/test/resources/host-jni/$hostPlatform").asFile.absolutePath
systemProperty("java.library.path", "$hostedPath:$libPath")
dependsOn("copyHostJni")
}} else {{
systemProperty("java.library.path", libPath)
}}
}}
tasks.matching {{ it.name.startsWith("processDebug") || it.name.startsWith("processRelease") }}.configureEach {{
if (project.properties["alef.skipHostJni"] != "true" && name.contains("UnitTestJavaRes")) {{
dependsOn("copyHostJni")
}}
}}"#,
jni_crate_path = jni_crate_path,
jni_lib_name = jni_lib_name,
test_env_block = test_env_block,
guarded_working_dir = guarded_working_dir,
)
};
let capsule_test_deps: String = {
let mut deps: Vec<(String, String)> = if dep_mode == crate::e2e::config::DependencyMode::Local {
capsule_types
.values()
.filter(|cap| !cap.package.is_empty())
.map(|cap| (cap.package.clone(), cap.package_version.clone()))
.collect()
} else {
Vec::new()
};
deps.sort();
deps.dedup();
deps.iter()
.map(|(coord, ver)| format!("\n testImplementation(\"{coord}:{ver}\")"))
.collect()
};
let test_deps = format!(
r#" // Jackson for JSON assertion helpers
testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson_annotations}")
testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
// jackson-module-kotlin registers constructors/properties for Kotlin data
// classes, which have no default constructor and cannot be deserialized by
// plain Jackson without this module.
testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:{jackson}")
// jspecify for null-safety annotations on wrapped types
testImplementation("org.jspecify:jspecify:{jspecify}")
// Kotlin coroutines for async test helpers
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")
// JUnit 5 API and engine
testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
{launcher_dep}
// Kotlin stdlib test helpers
testImplementation(kotlin("test"))
// JNA for loading the native library from java.library.path
testImplementation("net.java.dev.jna:jna:{jna}"){capsule_test_deps}
"#
);
format!(
r#"import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.ZipFile
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {{
id("com.android.library") version "{android_gradle_plugin}"{kotlin_android_plugin_line}
}}
group = "{kotlin_pkg_id}"
version = "0.1.0"
android {{
namespace = "{kotlin_pkg_id}.e2e"
compileSdk = 35
defaultConfig {{
minSdk = 21
}}
compileOptions {{
sourceCompatibility = JavaVersion.VERSION_{jvm_target}
targetCompatibility = JavaVersion.VERSION_{jvm_target}
}}{source_sets_block}
testOptions {{
// Host JVM unit tests: no Android device/emulator required.
// Tests run against the published AAR and JVM-side deps via `gradle test`.
unitTests {{
isReturnDefaultValues = true
}}
}}
}}
kotlin {{
// Set JVM target for compilation. gradle.properties enables auto-detection
// of host JDK installations so Gradle uses the available JDK version on the
// build machine, preventing provisioning failures when the target version is not installed.
jvmToolchain({jvm_target})
compilerOptions {{
jvmTarget = JvmTarget.JVM_{jvm_target}
}}
}}
// Repositories declared in settings.gradle.kts via
// dependencyResolutionManagement (FAIL_ON_PROJECT_REPOS). Re-declaring them
// here triggers Gradle "repository was added by build file" errors.
dependencies {{
{artifact_dep}
{test_deps}
}}
{tasks_block}
"#
)
}
pub(super) fn render_settings_gradle_kotlin_android(pkg_name: &str) -> String {
let project_name = sanitize_gradle_project_name(pkg_name);
let marker = crate::core::hash::SELF_MARKING_HEADER_LINE;
format!(
r#"{marker}
pluginManagement {{
repositories {{
google()
mavenCentral()
gradlePluginPortal()
}}
}}
plugins {{
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}}
dependencyResolutionManagement {{
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {{
google()
mavenCentral()
}}
}}
rootProject.name = "{project_name}-e2e"
"#
)
}
fn sanitize_gradle_project_name(pkg_name: &str) -> String {
let artifact = pkg_name.rsplit(':').next().unwrap_or(pkg_name);
artifact
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '<' | '>' | '"' | '?' | '*' | '|' => '-',
other => other,
})
.collect()
}
pub(super) fn render_gradle_properties() -> String {
r#"# Generated by alef. Do not edit by hand.
# Allow Gradle to auto-detect JDK installations when the requested
# toolchain version is not available. This prevents build failures on
# hosts with only newer or older JDK versions installed.
org.gradle.java.installations.auto-detect=true
# Configure Adoptium (Eclipse Temurin) as the download repository for
# missing JDK toolchains. When jvmToolchain(17) is requested but JDK 17
# is not found locally, Gradle will attempt to download it from this repo.
org.gradle.jvm.toolchain.download.repository=adoptium
# Increase heap for large multi-project builds.
org.gradle.jvmargs=-Xmx4g
"#
.to_string()
}