use crate::core::template_versions::{maven, toolchain};
pub(super) fn render_build_gradle_kotlin_android(
kotlin_pkg_id: &str,
maven_coordinate: &str,
dep_mode: crate::e2e::config::DependencyMode,
needs_mock_server: bool,
jni_lib_name: &str,
jni_crate_path: &str,
) -> String {
let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
let android_gradle_plugin = maven::ANDROID_GRADLE_PLUGIN;
let junit = maven::JUNIT;
let jackson = maven::JACKSON_E2E;
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 = if needs_mock_server {
format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
} else {
String::new()
};
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 jniCargoPath = "{jni_crate_path}/Cargo.toml"
val crateDir = jniCargoPath.substringBeforeLast("/Cargo.toml")
val workspaceTarget = file("../../target/release")
val crateTarget = file(crateDir).resolve("target/release")
val buildDir = if (workspaceTarget.exists()) workspaceTarget else crateTarget
val libName = when (hostPlatform) {{
"darwin" -> "lib{jni_lib_name}.dylib"
"windows" -> "{jni_lib_name}.dll"
else -> "lib{jni_lib_name}.so"
}}
from(buildDir) {{
include(libName)
}}
into(layout.projectDirectory.dir("src/test/resources/host-jni/$hostPlatform"))
}}
}}
tasks.withType<Test> {{
useJUnitPlatform()
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,
)
} else {
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 jniCargoPath = "{jni_crate_path}/Cargo.toml"
val crateDir = jniCargoPath.substringBeforeLast("/Cargo.toml")
val workspaceTarget = file("../../target/release")
val crateTarget = file(crateDir).resolve("target/release")
val buildDir = if (workspaceTarget.exists()) workspaceTarget else crateTarget
val libName = when (hostPlatform) {{
"darwin" -> "lib{jni_lib_name}.dylib"
"windows" -> "{jni_lib_name}.dll"
else -> "lib{jni_lib_name}.so"
}}
from(buildDir) {{
include(libName)
}}
into(layout.projectDirectory.dir("src/test/resources/host-jni/$hostPlatform"))
}}
}}
tasks.withType<Test> {{
useJUnitPlatform()
// Resolve the native library location (e.g., ../../target/release)
val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
systemProperty("java.library.path", libPath)
systemProperty("jna.library.path", libPath)
// Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/
workingDir = file("${{rootDir}}/../../test_documents")
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")
}}
}}"#,
jni_crate_path = jni_crate_path,
jni_lib_name = jni_lib_name,
)
};
let test_deps = format!(
r#" // Jackson for JSON assertion helpers
testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
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}")
"#
);
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") version "{kotlin_plugin}"
}}
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);
format!(
r#"// Generated by alef. Do not edit by hand.
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()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_gradle_kotlin_android_includes_jackson_module_kotlin() {
let output = render_build_gradle_kotlin_android(
"dev.sample_crate.samplellm.android",
"dev.sample_crate:demo-client-android:1.0.0",
crate::e2e::config::DependencyMode::Local,
false,
"demo_client_jni",
"../../crates/demo-client-jni",
);
assert!(
output.contains("jackson-module-kotlin"),
"build.gradle.kts must depend on jackson-module-kotlin, got:\n{output}"
);
}
#[test]
fn build_gradle_kotlin_android_registry_mode_emits_full_maven_coordinate() {
let output = render_build_gradle_kotlin_android(
"dev.sample_crate",
"dev.sample_crate:sample_crate-android:5.0.0-rc.1",
crate::e2e::config::DependencyMode::Registry,
false,
"sample_crate_jni",
"../../crates/sample_crate-jni",
);
assert!(
output.contains(r#"implementation("dev.sample_crate:sample_crate-android:5.0.0-rc.1")"#),
"build.gradle.kts must emit full Maven coordinate with groupId:artifactId:version, got:\n{output}"
);
}
#[test]
fn settings_gradle_kotlin_android_declares_plugin_repositories() {
let output = render_settings_gradle_kotlin_android("demo-client");
assert!(
output.contains("pluginManagement"),
"settings.gradle.kts must declare pluginManagement block, got:\n{output}"
);
assert!(
output.contains("google()"),
"pluginManagement repositories must include google(), got:\n{output}"
);
assert!(
output.contains("gradlePluginPortal()"),
"pluginManagement repositories must include gradlePluginPortal(), got:\n{output}"
);
assert!(
output.contains("rootProject.name = \"demo-client-e2e\""),
"rootProject.name must be derived from pkg_name, got:\n{output}"
);
}
#[test]
fn settings_gradle_kotlin_android_strips_maven_group_from_project_name() {
let output = render_settings_gradle_kotlin_android("dev.sample_crate:demo-markup-android");
assert!(
output.contains("rootProject.name = \"demo-markup-android-e2e\""),
"rootProject.name must strip Maven group prefix, got:\n{output}"
);
let project_name_line = output
.lines()
.find(|line| line.starts_with("rootProject.name"))
.expect("rootProject.name line must be emitted");
assert!(
!project_name_line.contains(':'),
"rootProject.name line must not contain Gradle-reserved ':', got:\n{project_name_line}"
);
}
#[test]
fn build_gradle_kotlin_android_registry_mode_includes_aar_verification_task() {
let output = render_build_gradle_kotlin_android(
"dev.sample_crate",
"dev.sample_crate:sample_crate-android:5.0.0-rc.1",
crate::e2e::config::DependencyMode::Registry,
false,
"sample_crate_jni",
"../../crates/sample_crate-jni",
);
assert!(
output.contains("verifyAarPublished"),
"registry-mode build.gradle.kts must include verifyAarPublished task, got:\n{output}"
);
assert!(
output.contains("startsWith(\"jni/\")"),
"verifyAarPublished task must check for jni/ directory, got:\n{output}"
);
assert!(
output.contains("classes.jar"),
"verifyAarPublished task must check for classes.jar, got:\n{output}"
);
assert!(
output.contains("dependsOn(\"verifyAarPublished\")"),
"Test task must depend on verifyAarPublished, got:\n{output}"
);
}
#[test]
fn build_gradle_kotlin_android_pins_jvm_toolchain_for_jdk25_host_compat() {
for dep_mode in [
crate::e2e::config::DependencyMode::Registry,
crate::e2e::config::DependencyMode::Local,
] {
let output = render_build_gradle_kotlin_android(
"dev.sample_crate",
"dev.sample_crate:sample_crate-android:5.0.0-rc.1",
dep_mode,
false,
"sample_crate_jni",
"../../crates/sample_crate-jni",
);
assert!(
output.contains("jvmToolchain(17)"),
"build.gradle.kts ({dep_mode:?}) must pin jvmToolchain(17) so JDK 25 hosts pick up JDK 17 for gradle, got:\n{output}"
);
}
}
#[test]
fn build_gradle_kotlin_android_local_mode_excludes_aar_verification_task() {
let output = render_build_gradle_kotlin_android(
"dev.sample_crate.samplellm.android",
"dev.sample_crate:demo-client-android:1.0.0",
crate::e2e::config::DependencyMode::Local,
false,
"demo_client_jni",
"../../crates/demo-client-jni",
);
assert!(
!output.contains("verifyAarPublished"),
"local-mode build.gradle.kts must not include verifyAarPublished task, got:\n{output}"
);
}
#[test]
fn build_gradle_kotlin_android_includes_host_jni_tasks() {
for dep_mode in [
crate::e2e::config::DependencyMode::Registry,
crate::e2e::config::DependencyMode::Local,
] {
let output = render_build_gradle_kotlin_android(
"dev.sample_crate",
"dev.sample_crate:sample_crate-android:5.0.0-rc.1",
dep_mode,
false,
"sample_crate_jni",
"../../crates/sample_crate-jni",
);
assert!(
output.contains(r#"tasks.register("buildHostJni", Exec::class)"#),
"build.gradle.kts ({dep_mode:?}) must include buildHostJni task registration, got:\n{output}"
);
assert!(
output.contains(r#"tasks.register("copyHostJni", Copy::class)"#),
"build.gradle.kts ({dep_mode:?}) must include copyHostJni task registration, got:\n{output}"
);
assert!(
output.contains("java.library.path"),
"build.gradle.kts ({dep_mode:?}) must set java.library.path for the Test task, got:\n{output}"
);
assert!(
output.contains(r#"src/test/resources/host-jni"#),
"build.gradle.kts ({dep_mode:?}) must reference src/test/resources/host-jni, got:\n{output}"
);
}
}
#[test]
fn build_gradle_kotlin_android_build_host_jni_uses_parameterized_jni_crate_path() {
let output = render_build_gradle_kotlin_android(
"dev.sample_crate",
"dev.sample_crate:sample_crate-android:5.0.0-rc.1",
crate::e2e::config::DependencyMode::Local,
false,
"sample_crate_jni",
"../../crates/sample_crate-jni",
);
assert!(
output.contains("../../crates/sample_crate-jni/Cargo.toml"),
"buildHostJni must pass the parameterized JNI crate path to cargo build, got:\n{output}"
);
assert!(
output.contains(r#"commandLine("cargo", "build", "--release", "--manifest-path", jniCargoPath)"#),
"buildHostJni must invoke cargo build with --release flag, got:\n{output}"
);
}
#[test]
fn build_gradle_kotlin_android_copy_host_jni_uses_parameterized_jni_lib_name() {
let output = render_build_gradle_kotlin_android(
"dev.sample_crate",
"dev.sample_crate:sample_crate-android:5.0.0-rc.1",
crate::e2e::config::DependencyMode::Registry,
false,
"sample_crate_jni",
"../../crates/sample_crate-jni",
);
assert!(
output.contains("libsample_crate_jni.dylib"),
"copyHostJni must emit macOS library name with parameterized JNI lib name, got:\n{output}"
);
assert!(
output.contains("sample_crate_jni.dll"),
"copyHostJni must emit Windows library name with parameterized JNI lib name, got:\n{output}"
);
assert!(
output.contains("libsample_crate_jni.so"),
"copyHostJni must emit Linux library name with parameterized JNI lib name, got:\n{output}"
);
}
}