// file: Android/gradle/sasedev-rust-android.gradle // version: 2 import java.util.Collections import java.util.zip.ZipFile import javax.inject.Inject import org.gradle.process.ExecOperations abstract class BuildSasedevRustAndroidTask extends DefaultTask { @InputFile @PathSensitive(PathSensitivity.RELATIVE) abstract RegularFileProperty getSdl3Aar() @InputFiles @PathSensitive(PathSensitivity.RELATIVE) abstract ConfigurableFileCollection getRustInputs() @Input abstract Property getGameFeature() @Input abstract Property getAbi() @Input abstract Property getAndroidApi() @Input abstract Property getNdkVersion() @Input abstract Property getReleaseBuild() @Internal abstract DirectoryProperty getRepositoryRoot() @OutputDirectory abstract DirectoryProperty getOutputDirectory() @Inject abstract ExecOperations getExecOperations() @TaskAction void buildNative() { def selectedAbi = abi.get() def selectedFeature = gameFeature.get() def aar = sdl3Aar.get().asFile def repository = repositoryRoot.get().asFile def output = outputDirectory.get().asFile def runtimeDirectory = new File(output, selectedAbi) def linkDirectory = new File(temporaryDir, "link") project.delete(output) project.delete(linkDirectory) runtimeDirectory.mkdirs() linkDirectory.mkdirs() byte[] sdl3Payload new ZipFile(aar).withCloseable { archive -> def candidates = [ "prefab/modules/SDL3/libs/android.${selectedAbi}/libSDL3.so", "jni/${selectedAbi}/libSDL3.so", ] def entry = candidates.collect { candidate -> archive.getEntry(candidate) }.find { candidate -> candidate != null } if (entry == null) { def suffix = "/libs/android.${selectedAbi}/libSDL3.so" def fallback = Collections.list(archive.entries()).findAll { candidate -> !candidate.directory && candidate.name.endsWith(suffix) } if (fallback.size() != 1) { throw new GradleException( "SDL3 shared library for ABI ${selectedAbi} is missing from ${aar}; " + "expected Prefab member ${candidates[0]}" ) } entry = fallback[0] } sdl3Payload = archive.getInputStream(entry).withCloseable { stream -> stream.bytes } } def linkLibrary = new File(linkDirectory, "libSDL3.so") def runtimeLibrary = new File(runtimeDirectory, "libSDL3.so") linkLibrary.bytes = sdl3Payload runtimeLibrary.bytes = sdl3Payload def androidHome = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") if (!androidHome) { throw new GradleException("ANDROID_HOME or ANDROID_SDK_ROOT is required to resolve the project NDK") } def configuredNdk = new File(androidHome, "ndk/${ndkVersion.get()}") if (!configuredNdk.isDirectory()) { throw new GradleException("configured Android NDK is missing: ${configuredNdk}") } def inheritedRustFlags = System.getenv("RUSTFLAGS")?.trim() def linkFlag = "-Lnative=${linkDirectory}" def rustFlags = inheritedRustFlags ? "${inheritedRustFlags} ${linkFlag}" : linkFlag def cargoCommand = [ "cargo", "ndk", "-t", selectedAbi, "-o", output.absolutePath, "build", "-p", "game-android-entrypoint", ] if (releaseBuild.get()) { cargoCommand.add("--release") } cargoCommand.addAll([ "--no-default-features", "--features", selectedFeature, ]) execOperations.exec { spec -> spec.workingDir(repository) spec.environment("ANDROID_NDK_HOME", configuredNdk.absolutePath) spec.environment("CARGO_NDK_PLATFORM", androidApi.get().toString()) spec.environment("RUSTFLAGS", rustFlags) spec.commandLine(cargoCommand) } def rustLibrary = new File(runtimeDirectory, "libgame_android_entrypoint.so") if (!runtimeLibrary.isFile()) { throw new GradleException("missing staged SDL3 Android library: ${runtimeLibrary}") } if (!rustLibrary.isFile()) { throw new GradleException("missing Rust Android library after cargo-ndk build: ${rustLibrary}") } } } if (!project.ext.has("sasedevRustGameFeature")) { throw new GradleException("sasedevRustGameFeature must select exactly one Cargo game feature") } if (!project.ext.has("sasedevRustAndroidAbis")) { throw new GradleException("sasedevRustAndroidAbis must declare at least one Android ABI") } if (!project.ext.has("sasedevRustAndroidApi")) { throw new GradleException("sasedevRustAndroidApi must declare the Rust Android API level") } def selectedGameFeature = project.ext.sasedevRustGameFeature as String def selectedAbis = (project.ext.sasedevRustAndroidAbis as List).collect { configuredAbi -> configuredAbi as String } def selectedAndroidApi = project.ext.sasedevRustAndroidApi as Integer def configuredSdl3AarName = providers.gradleProperty("sdl3AarName").get() def configuredNdkVersion = providers.gradleProperty("androidNdkVersion").get() def repositoryDirectory = rootProject.projectDir.parentFile def supportedAbis = ["arm64-v8a", "armeabi-v7a", "x86_64", "x86"] as Set if (selectedAbis.isEmpty()) { throw new GradleException("sasedevRustAndroidAbis must not be empty") } if (selectedAbis.toSet().size() != selectedAbis.size()) { throw new GradleException("sasedevRustAndroidAbis must not contain duplicates: ${selectedAbis}") } def unsupportedAbis = selectedAbis.findAll { configuredAbi -> !supportedAbis.contains(configuredAbi) } if (!unsupportedAbis.isEmpty()) { throw new GradleException("unsupported Rust Android ABI(s): ${unsupportedAbis.join(', ')}") } androidComponents { onVariants(selector().all()) { variant -> def selectedReleaseBuild = variant.buildType == "release" selectedAbis.each { selectedAbi -> def variantSuffix = variant.name.substring(0, 1).toUpperCase() + variant.name.substring(1) def abiSuffix = selectedAbi .split(/[^A-Za-z0-9]+/) .findAll { segment -> !segment.isEmpty() } .collect { segment -> segment.substring(0, 1).toUpperCase() + segment.substring(1) } .join("") def nativeTask = tasks.register( "build${variantSuffix}SasedevRust${abiSuffix}", BuildSasedevRustAndroidTask, ) { sdl3Aar.set(rootProject.layout.projectDirectory.file("libs/${configuredSdl3AarName}")) rustInputs.from(rootProject.file("../Cargo.toml")) rustInputs.from(rootProject.file("../.cargo/config.toml")) rustInputs.from(rootProject.fileTree("../crates") { include "**/*.rs" include "**/Cargo.toml" include "**/build.rs" }) gameFeature.set(selectedGameFeature) abi.set(selectedAbi) androidApi.set(selectedAndroidApi) ndkVersion.set(configuredNdkVersion) releaseBuild.set(selectedReleaseBuild) repositoryRoot.set(repositoryDirectory) outputDirectory.set(layout.buildDirectory.dir("generated/sasedevNative/${variant.name}/${selectedAbi}/jniLibs")) } if (variant.sources.jniLibs == null) { throw new GradleException("AGP variant ${variant.name} does not expose jniLibs sources") } variant.sources.jniLibs.addGeneratedSourceDirectory(nativeTask) { task -> task.outputDirectory } } } }