import com.android.build.api.dsl.LibraryExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension import groovy.json.JsonSlurper import java.io.File import java.util.concurrent.Callable import javax.inject.Inject import org.gradle.process.ExecOperations import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false } val compileSdkVer = libs.versions.compileSdk.get() val minSdkVer = libs.versions.minSdk.get() val javaVer = libs.versions.javaVersion.get() val revengeApiDep = libs.revenge.api.get().toString() val xposedApiDep = libs.xposed.api.get().toString() val coroutinesDep = libs.kotlinx.coroutines.android.get().toString() val androidLibraryPluginId = libs.plugins.android.library.get().pluginId // Gradle 9 removed Project.exec from task actions interface ExecOpsProvider { @get:Inject val execOps: ExecOperations } val rootExecOps = objects.newInstance().execOps // `./gradlew clean packageAllPlugins` also runs every `:plugins::clean`, // which would delete freshly built artifacts mid-build; the tasks below `mustRunAfter` these. val cleanTasks = Callable { rootProject.allprojects.mapNotNull { it.tasks.findByName("clean") } } // --------------------------------------------------------------------------------------------- // Plugins declared under plugins//manifest.json // --------------------------------------------------------------------------------------------- @Suppress("UNCHECKED_CAST") fun parseJson(file: File) = JsonSlurper().parse(file) as Map @Suppress("UNCHECKED_CAST") fun Map.obj(key: String) = this[key] as? Map fun Map.str(key: String) = this[key] as? String data class PluginDef(val dir: File, val id: String, val jarName: String?, val scriptName: String?) val pluginDefs = file("plugins").listFiles().orEmpty() .filter(File::isDirectory) .sortedBy(File::getName) .mapNotNull { dir -> val manifest = File(dir, "manifest.json").takeIf(File::isFile)?.let(::parseJson) ?: return@mapNotNull null val id = manifest.str("id") ?: return@mapNotNull null val dist = manifest.obj("dist").orEmpty() PluginDef(dir, id, dist.obj("android")?.str("path"), dist.str("script")) } // --------------------------------------------------------------------------------------------- // JS toolchain discovery // --------------------------------------------------------------------------------------------- // Gradle's exec runs with a minimal, non-login PATH that usually omits user-level install dirs, // so the well-known locations are probed explicitly. data class JsTool(val kind: String, val exe: File) { val installCommand: List get() = when (kind) { "bun" -> listOf(exe.absolutePath, "install", "--silent") "npm" -> listOf(exe.absolutePath, "install", "--no-audit", "--no-fund", "--silent") "deno" -> listOf(exe.absolutePath, "install", "--quiet") else -> error("Unknown JS tool: $kind") } val buildCommand: List get() = when (kind) { "bun", "npm" -> listOf(exe.absolutePath, "run", "build") // Deno creates no node_modules/.bin shims, so `deno task build` cannot resolve the CLI, // so we run its bin file directly. "deno" -> listOf( exe.absolutePath, "run", "-A", "node_modules/@revenge-mod/plugin-cli/bin/revenge-plugin.js", "build", ) else -> error("Unknown JS tool: $kind") } } fun findJsTool(): JsTool? { val kindOverride = findProperty("jsTool") as String? val pathOverride = (findProperty("jsToolPath") as String?) ?: System.getenv("BUN") if (!pathOverride.isNullOrBlank()) { val exe = File(pathOverride).takeIf(File::isFile) ?: return null return JsTool(kindOverride ?: exe.name.substringBefore('.'), exe) } val homes = buildSet { System.getProperty("user.home")?.let(::add) System.getenv("HOME")?.let(::add) System.getProperty("user.name")?.let { user -> add("/home/$user") add("/var/home/$user") // atomic/ostree distros: /home -> /var/home } } val pathDirs = System.getenv("PATH").orEmpty().split(File.pathSeparator).filter(String::isNotEmpty) fun candidatesFor(kind: String): List = buildList { pathDirs.forEach { add(File(it, kind)) } when (kind) { "bun" -> homes.forEach { add(File(it, ".bun/bin/bun")) } // nvm keeps npm next to node, under versions/node//bin. "npm" -> homes.forEach { home -> File(home, ".nvm/versions/node").listFiles() ?.sortedDescending() ?.forEach { add(File(it, "bin/npm")) } } "deno" -> homes.forEach { add(File(it, ".deno/bin/deno")) } } add(File("/usr/local/bin/$kind")) add(File("/opt/homebrew/bin/$kind")) } val kinds = kindOverride?.let(::listOf) ?: listOf("bun", "npm", "deno") return kinds.firstNotNullOfOrNull { kind -> candidatesFor(kind).firstOrNull(File::isFile)?.let { JsTool(kind, it) } } } // --------------------------------------------------------------------------------------------- // Native plugin modules (:plugins:) are configured entirely from here, // so a native plugin needs nothing but a src/main folder and a manifest.json. // --------------------------------------------------------------------------------------------- configure(subprojects.filter { it.path.startsWith(":plugins:") }) { // AGP 9 has built-in Kotlin support, so org.jetbrains.kotlin.android isn't applied. pluginManager.apply(androidLibraryPluginId) extensions.configure { namespace = "revenge.plugin." + name.replace(Regex("[^A-Za-z0-9]"), "_") compileSdk = compileSdkVer.toInt() defaultConfig { minSdk = minSdkVer.toInt() } compileOptions { sourceCompatibility = JavaVersion.toVersion(javaVer) targetCompatibility = JavaVersion.toVersion(javaVer) } sourceSets { named("main") { kotlin.directories += "src/main/kotlin" } } } extensions.configure { jvmToolchain(javaVer.toInt()) } // AGP's lint tasks require the module's build.gradle as an input, which these script-less modules don't have. // There is nothing to lint here anyway. tasks.configureEach { if (name.contains("Lint") || name.startsWith("lint")) enabled = false } dependencies { // Provided at runtime by the host, so never bundled add("compileOnly", revengeApiDep) add("compileOnly", xposedApiDep) add("compileOnly", coroutinesDep) } val androidExt = extensions.getByType() val sdkDirProvider = extensions.getByType().sdkComponents.sdkDirectory val execOps = objects.newInstance().execOps tasks.register("dexJar") { group = "revenge" description = "Compiles this plugin into a DEX-containing JAR loadable by DexClassLoader." dependsOn("compileReleaseKotlin", "compileReleaseJavaWithJavac") mustRunAfter(cleanTasks) // Only this module's own classes are DEXed. Dependencies compileOnly. val classDirs = files( // AGP 9 built-in Kotlin output, then the pre-AGP 9 location layout.buildDirectory.dir("intermediates/built_in_kotlinc/release/compileReleaseKotlin/classes"), layout.buildDirectory.dir("tmp/kotlin-classes/release"), layout.buildDirectory.dir("intermediates/javac/release/classes"), ) inputs.files(classDirs).withPropertyName("classDirs").optional() val outJar = layout.buildDirectory.file("outputs/plugin/plugin.jar") outputs.file(outJar).withPropertyName("dexJar") val buildToolsVersion = androidExt.buildToolsVersion doLast { val classFiles = classDirs.asFileTree.matching { include("**/*.class") }.files if (classFiles.isEmpty()) error("No compiled classes found to dex in $path.") val sdkDir = sdkDirProvider.get().asFile // Use newest d8 as the AGP-default build-tools could ship one too old for class files from a newer toolchain val d8 = File(sdkDir, "build-tools").listFiles() ?.sortedByDescending(File::getName) ?.map { File(it, "d8") } ?.firstOrNull(File::isFile) ?: file("$sdkDir/build-tools/$buildToolsVersion/d8") val androidJar = file("$sdkDir/platforms/android-$compileSdkVer/android.jar") require(d8.exists()) { "d8 not found at $d8" } require(androidJar.exists()) { "android.jar not found at $androidJar" } val out = outJar.get().asFile out.parentFile.mkdirs() execOps.exec { commandLine( d8.absolutePath, "--min-api", minSdkVer, "--lib", androidJar.absolutePath, "--output", out.absolutePath, *classFiles.map { it.absolutePath }.toTypedArray(), ) } logger.lifecycle("Dexed $path -> ${out.relativeTo(rootProject.projectDir)}") } } } // --------------------------------------------------------------------------------------------- // JS bundles (all plugins at once) and per-plugin packaging into build/dist/.zip // --------------------------------------------------------------------------------------------- val buildJs = tasks.register("buildJs") { group = "revenge" description = "Builds every plugin's JS bundle via the revenge-plugin CLI." mustRunAfter(cleanTasks) // The bundler is @revenge-mod/plugin-cli, so the lockfiles (which pin its revision) are the inputs. inputs.files( "package.json", "tsconfig.json", "biome.json", "bun.lock", "bun.lockb", "package-lock.json", "deno.lock", ).withPropertyName("config") // Everything a plugin's bundle can be built from. inputs.files(fileTree("plugins") { exclude("*/build/**", "*/src/**") }).withPropertyName("sources") pluginDefs.filter { it.scriptName != null }.forEach { outputs.dir(File(it.dir, "build/js")).withPropertyName("bundle-${it.dir.name}") } doLast { val tool = findJsTool() if (tool == null) { logger.warn( "No JS toolchain found (bun, npm, deno). Install Node >= 22.18, Bun or Deno 2, " + "or pass -PjsTool=/-PjsToolPath=. Skipping JS build.", ) return@doLast } logger.lifecycle("Building JS bundles with ${tool.kind} (${tool.exe})") // Put the tool's own dir on PATH so nested calls (npm -> node, script -> bin shims) resolve. val path = tool.exe.parentFile.absolutePath + File.pathSeparator + System.getenv("PATH").orEmpty() listOf(tool.installCommand, tool.buildCommand).forEach { cmd -> rootExecOps.exec { workingDir = rootDir environment("PATH", path) commandLine(cmd) } } } } val packageAllPlugins = tasks.register("packageAllPlugins") { group = "revenge" description = "Builds and packages every plugin into build/dist/.zip." } fun taskSuffix(dirName: String): String = dirName.split(Regex("[^A-Za-z0-9]")).filter(String::isNotEmpty) .joinToString("") { it.replaceFirstChar(Char::uppercase) } pluginDefs.forEach { (dir, id, jarName, scriptName) -> val pkg = tasks.register("package${taskSuffix(dir.name)}") { group = "revenge" description = "Packages '$id' into a distributable ZIP." dependsOn(buildJs) mustRunAfter(cleanTasks) from(File(dir, "manifest.json")) if (jarName != null && File(dir, "src/main").isDirectory) { val nativePath = ":plugins:${dir.name}" dependsOn("$nativePath:dexJar") from(provider { project(nativePath).tasks.named("dexJar").get().outputs.files.singleFile }) { rename { jarName } } } if (scriptName != null) { from(File(dir, "build/js")) { include("index.js") rename { scriptName } } } archiveFileName.set("$id.zip") destinationDirectory.set(layout.buildDirectory.dir("dist")) doLast { logger.lifecycle("Packaged $id -> ${archiveFile.get().asFile.relativeTo(rootDir)}") } } packageAllPlugins.configure { dependsOn(pkg) } }