diff --git a/.gitignore b/.gitignore index d1c24feed7..af44d5bf1c 100755 --- a/.gitignore +++ b/.gitignore @@ -137,6 +137,7 @@ tests/test-home # Plugin build artifacts plugin-api.jar plugin-artifacts.zip +plugin-maven-repo.zip # Other IDEs .cursor/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 90545a5698..2f4fdf7ddc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -433,6 +433,104 @@ tasks.register("createPluginArtifactsZip") { destinationDirectory.set(rootProject.file("assets")) } +// Fat compile-only jar published as com.itsaky.androidide:plugin-api:1.0.0. +// Merges the API surface plugins already compile against (plugin-api + common + +// eventbus-events + idetooltips) into one coordinate. The three add-ons are +// v7/v8-flavored (unlike plugin-api); their classes are ABI-neutral so v8 is used. +tasks.register("assemblePluginApiFatJar") { + dependsOn( + ":plugin-api:assembleRelease", + ":common:assembleV8Release", + ":eventbus-events:assembleV8Release", + ":idetooltips:assembleV8Release", + ) + archiveFileName.set("plugin-api-1.0.0.jar") + destinationDirectory.set(layout.buildDirectory.dir("plugin-maven-repo-staging")) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + from( + zipTree( + project(":plugin-api") + .layout.buildDirectory + .file("intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar") + .get() + .asFile, + ), + ) + from( + zipTree( + project(":common") + .layout.buildDirectory + .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar") + .get() + .asFile, + ), + ) + from( + zipTree( + project(":eventbus-events") + .layout.buildDirectory + .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar") + .get() + .asFile, + ), + ) + from( + zipTree( + project(":idetooltips") + .layout.buildDirectory + .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar") + .get() + .asFile, + ), + ) +} + +// Dependency-free POM for the fat plugin-api coordinate: it is compile-only/provided, +// so it must NOT drag transitives that would need offline resolution. +tasks.register("writePluginApiPom") { + val pomFile = layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.pom") + outputs.file(pomFile) + doLast { + val file = pomFile.get().asFile + file.parentFile.mkdirs() + file.writeText( + """ + + 4.0.0 + com.itsaky.androidide + plugin-api + 1.0.0 + jar + +""", + ) + } +} + +// Assembles the shippable Maven layout: the fat plugin-api coordinate + the +// builder impl/POM/marker published by the plugin-builder included build. +tasks.register("createPluginMavenRepoZip") { + dependsOn("assemblePluginApiFatJar", "writePluginApiPom") + dependsOn( + gradle + .includedBuild("plugin-builder") + .task(":publishAllPublicationsToPluginMavenRepoRepository"), + ) + + archiveFileName.set("plugin-maven-repo.zip") + destinationDirectory.set(rootProject.file("assets")) + + into("com/itsaky/androidide/plugin-api/1.0.0") { + from(layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.jar")) + from(layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.pom")) + } + // Builder tree is already in Maven layout (com/itsaky/androidide/plugins/...). + from(rootProject.file("plugin-api/plugin-builder/build/plugin-maven-repo")) +} + // Packages the on-device installer payload (assets-.zip) consumed by // SplitAssetsInstaller on debug builds. Entry names must match the installer // contract in AssetsInstallationHelper.expectedEntries. @@ -459,6 +557,7 @@ fun createAssetsZip(arch: String) { "documentation.db", bootstrapName, "plugin-artifacts.zip", + "plugin-maven-repo.zip", "core.cgt", ).forEach { fileName -> val filePath = sourceDir.resolve(fileName) @@ -484,6 +583,7 @@ fun createAssetsZip(arch: String) { tasks.register("assembleV8Assets") { dependsOn("createPluginArtifactsZip") + dependsOn("createPluginMavenRepoZip") if (!isCiCd) { dependsOn("assetsDownloadDebug") } @@ -494,6 +594,7 @@ tasks.register("assembleV8Assets") { tasks.register("assembleV7Assets") { dependsOn("createPluginArtifactsZip") + dependsOn("createPluginMavenRepoZip") if (!isCiCd) { dependsOn("assetsDownloadDebug") } diff --git a/app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt b/app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt index 5a12ae118f..9c7b46297a 100644 --- a/app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt +++ b/app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt @@ -20,6 +20,7 @@ import org.adfa.constants.GRADLE_API_NAME_JAR_BR import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME import org.adfa.constants.LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME +import org.adfa.constants.PLUGIN_MAVEN_REPO_ZIP_BR import org.adfa.constants.TEMPLATE_CORE_ARCHIVE import org.adfa.constants.TEMPLATE_CORE_ARCHIVE_BR import org.slf4j.LoggerFactory @@ -55,7 +56,6 @@ data object BundledAssetsInstaller : BaseAssetsInstaller() { when (entryName) { GRADLE_DISTRIBUTION_ARCHIVE_NAME, ANDROID_SDK_ZIP, - LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME, -> { val destDir = destinationDirForArchiveEntry(entryName).toPath() if (Files.exists(destDir)) { @@ -70,6 +70,27 @@ data object BundledAssetsInstaller : BaseAssetsInstaller() { } } + LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> { + val destDir = destinationDirForArchiveEntry(entryName).toPath() + if (Files.exists(destDir)) { + destDir.deleteRecursively() + } + Files.createDirectories(destDir) + // 1) harvested repo + assets.open(ToolsManager.getCommonAsset("$entryName.br")).use { assetStream -> + BrotliInputStream(assetStream).use { srcStream -> + AssetsInstallationHelper.extractZipToDir(srcStream, destDir) + } + } + // 2) plugin coordinate overlay -- merged (no wipe) into the same repo + assets.open(ToolsManager.getCommonAsset(PLUGIN_MAVEN_REPO_ZIP_BR)).use { assetStream -> + BrotliInputStream(assetStream).use { srcStream -> + AssetsInstallationHelper.extractZipToDir(srcStream, destDir) + } + } + logger.debug("Merged plugin coordinates into {}", destDir) + } + GRADLE_API_NAME_JAR_ZIP -> { val assetPath = ToolsManager.getCommonAsset(GRADLE_API_NAME_JAR_BR) BrotliInputStream(assets.open(assetPath)).use { input -> @@ -80,47 +101,51 @@ data object BundledAssetsInstaller : BaseAssetsInstaller() { } } - TEMPLATE_CORE_ARCHIVE -> { - val assetPath = ToolsManager.getCommonAsset(TEMPLATE_CORE_ARCHIVE_BR) - BrotliInputStream(assets.open(assetPath)).use { input -> - val destFile = Environment.TEMPLATES_DIR.resolve(TEMPLATE_CORE_ARCHIVE) - destFile.outputStream().use { output -> - input.copyTo(output) - } - } - } - - AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> { + TEMPLATE_CORE_ARCHIVE -> { + val assetPath = ToolsManager.getCommonAsset(TEMPLATE_CORE_ARCHIVE_BR) + BrotliInputStream(assets.open(assetPath)).use { input -> + val destFile = Environment.TEMPLATES_DIR.resolve(TEMPLATE_CORE_ARCHIVE) + destFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + + AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> { val assetPath = ToolsManager.getCommonAsset("${AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME}.br") - val result = retryOnceOnNoSuchFile ( - onFirstFailure = { Files.createDirectories(stagingDir) }, - onSecondFailure = { e2 -> - throw IOException( - context.getString(R.string.terminal_installation_failed_low_storage), - e2 - ) - } - ) { - withTempZipChannel( - stagingDir = stagingDir, - prefix = "bootstrap", - writeTo = { path -> writeBrotliAssetToPath(context, assetPath, path) }, - useChannel = { ch -> TerminalInstaller.installIfNeeded(context, ch) } - ) - } + val result = + retryOnceOnNoSuchFile( + onFirstFailure = { Files.createDirectories(stagingDir) }, + onSecondFailure = { e2 -> + throw IOException( + context.getString(R.string.terminal_installation_failed_low_storage), + e2, + ) + }, + ) { + withTempZipChannel( + stagingDir = stagingDir, + prefix = "bootstrap", + writeTo = { path -> writeBrotliAssetToPath(context, assetPath, path) }, + useChannel = { ch -> TerminalInstaller.installIfNeeded(context, ch) }, + ) + } when (result) { is TerminalInstaller.InstallResult.Success -> {} + is TerminalInstaller.InstallResult.Error.Interactive -> { throw IOException("${result.title}: ${result.message}") } + is TerminalInstaller.InstallResult.Error.IsSecondaryUser -> { throw IOException( - context.getString(R.string.terminal_installation_failed_secondary_user) + context.getString(R.string.terminal_installation_failed_secondary_user), ) } + is TerminalInstaller.InstallResult.NotInstalled -> { throw IllegalStateException("Terminal installation failed: NotInstalled state") } @@ -134,61 +159,67 @@ data object BundledAssetsInstaller : BaseAssetsInstaller() { } } } - AssetsInstallationHelper.PLUGIN_ARTIFACTS_ZIP -> { - logger.debug("Extracting plugin artifacts from '{}'", entryName) - val pluginDir = Environment.PLUGIN_API_JAR.parentFile - ?: throw IllegalStateException("Plugin API parent directory is null") - val pluginDirPath = pluginDir.toPath().toAbsolutePath().normalize() - if (Files.exists(pluginDirPath)) { - pluginDirPath.deleteRecursively() - } - Files.createDirectories(pluginDirPath) - - val assetPath = ToolsManager.getCommonAsset("$entryName.br") - assets.open(assetPath).use { assetStream -> - BrotliInputStream(assetStream).use { brotliStream -> - ZipInputStream(brotliStream).use { pluginZip -> - var pluginEntry = pluginZip.nextEntry - while (pluginEntry != null) { - if (!pluginEntry.isDirectory) { - val targetPath = pluginDirPath.resolve(pluginEntry.name).normalize() - // Security check: prevent path traversal attacks - if (!targetPath.startsWith(pluginDirPath)) { - throw IllegalStateException( - "Zip entry '${pluginEntry.name}' would escape target directory" - ) - } - val targetFile = targetPath.toFile() - targetFile.parentFile?.mkdirs() - logger.debug("Extracting '{}' to {}", pluginEntry.name, targetFile) - targetFile.outputStream().use { output -> - pluginZip.copyTo(output) - } - } - pluginEntry = pluginZip.nextEntry - } - } - } - } - logger.debug("Completed extracting plugin artifacts") - } - else -> throw IllegalStateException("Unknown entry: $entryName") + + AssetsInstallationHelper.PLUGIN_ARTIFACTS_ZIP -> { + logger.debug("Extracting plugin artifacts from '{}'", entryName) + val pluginDir = + Environment.PLUGIN_API_JAR.parentFile + ?: throw IllegalStateException("Plugin API parent directory is null") + val pluginDirPath = pluginDir.toPath().toAbsolutePath().normalize() + if (Files.exists(pluginDirPath)) { + pluginDirPath.deleteRecursively() + } + Files.createDirectories(pluginDirPath) + + val assetPath = ToolsManager.getCommonAsset("$entryName.br") + assets.open(assetPath).use { assetStream -> + BrotliInputStream(assetStream).use { brotliStream -> + ZipInputStream(brotliStream).use { pluginZip -> + var pluginEntry = pluginZip.nextEntry + while (pluginEntry != null) { + if (!pluginEntry.isDirectory) { + val targetPath = pluginDirPath.resolve(pluginEntry.name).normalize() + // Security check: prevent path traversal attacks + if (!targetPath.startsWith(pluginDirPath)) { + throw IllegalStateException( + "Zip entry '${pluginEntry.name}' would escape target directory", + ) + } + val targetFile = targetPath.toFile() + targetFile.parentFile?.mkdirs() + logger.debug("Extracting '{}' to {}", pluginEntry.name, targetFile) + targetFile.outputStream().use { output -> + pluginZip.copyTo(output) + } + } + pluginEntry = pluginZip.nextEntry + } + } + } + } + logger.debug("Completed extracting plugin artifacts") + } + + else -> { + throw IllegalStateException("Unknown entry: $entryName") + } } } - override fun expectedSize(entryName: String): Long = when (entryName) { - GRADLE_DISTRIBUTION_ARCHIVE_NAME -> 63399283L - ANDROID_SDK_ZIP -> 254814511L - DOCUMENTATION_DB -> 297763377L - LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> 97485855L - AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> 124120151L - GRADLE_API_NAME_JAR_ZIP -> 29447748L - AssetsInstallationHelper.PLUGIN_ARTIFACTS_ZIP -> 86442L - TEMPLATE_CORE_ARCHIVE -> 133120L - else -> 0L - } - - private fun destinationDirForArchiveEntry(entryName: String): File = + override fun expectedSize(entryName: String): Long = + when (entryName) { + GRADLE_DISTRIBUTION_ARCHIVE_NAME -> 63399283L + ANDROID_SDK_ZIP -> 254814511L + DOCUMENTATION_DB -> 297763377L + LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> 97485855L + AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> 124120151L + GRADLE_API_NAME_JAR_ZIP -> 29447748L + AssetsInstallationHelper.PLUGIN_ARTIFACTS_ZIP -> 86442L + TEMPLATE_CORE_ARCHIVE -> 133120L + else -> 0L + } + + private fun destinationDirForArchiveEntry(entryName: String): File = when (entryName) { GRADLE_DISTRIBUTION_ARCHIVE_NAME -> Environment.GRADLE_DISTS ANDROID_SDK_ZIP -> Environment.ANDROID_HOME diff --git a/app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt b/app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt index 1114cb5fe1..a7268c2215 100644 --- a/app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt +++ b/app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt @@ -15,6 +15,7 @@ import org.adfa.constants.DOCUMENTATION_DB import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME import org.adfa.constants.LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME +import org.adfa.constants.PLUGIN_MAVEN_REPO_ZIP_NAME import org.adfa.constants.TEMPLATE_CORE_ARCHIVE import org.slf4j.LoggerFactory import java.io.File @@ -22,9 +23,9 @@ import java.io.FileNotFoundException import java.nio.file.Files import java.nio.file.Path import java.util.zip.ZipFile +import java.util.zip.ZipInputStream import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.deleteRecursively -import java.util.zip.ZipInputStream import kotlin.system.measureTimeMillis data object SplitAssetsInstaller : BaseAssetsInstaller() { @@ -37,8 +38,10 @@ data object SplitAssetsInstaller : BaseAssetsInstaller() { ): Unit = withContext(Dispatchers.IO) { if (!Environment.SPLIT_ASSETS_ZIP.exists()) { - throw FileNotFoundException("Assets zip file not found at path: ${Environment.SPLIT_ASSETS_ZIP.path}." + - " Please check Slack #qa-testing-builds channel for the latest version.") + throw FileNotFoundException( + "Assets zip file not found at path: ${Environment.SPLIT_ASSETS_ZIP.path}." + + " Please check Slack #qa-testing-builds channel for the latest version.", + ) } zipFile = ZipFile(Environment.SPLIT_ASSETS_ZIP) @@ -53,15 +56,15 @@ data object SplitAssetsInstaller : BaseAssetsInstaller() { entryName: String, ): Unit = withContext(Dispatchers.IO) { - val entry = zipFile.getEntry(entryName) - ?: throw FileNotFoundException(context.getString(R.string.err_asset_entry_not_found, entryName)) + val entry = + zipFile.getEntry(entryName) + ?: throw FileNotFoundException(context.getString(R.string.err_asset_entry_not_found, entryName)) val time = measureTimeMillis { zipFile.getInputStream(entry).use { zipInput -> when (entry.name) { GRADLE_DISTRIBUTION_ARCHIVE_NAME, ANDROID_SDK_ZIP, - LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME, GRADLE_API_NAME_JAR_ZIP, -> { val destDir = destinationDirForArchiveEntry(entry.name).toPath() @@ -74,38 +77,60 @@ data object SplitAssetsInstaller : BaseAssetsInstaller() { logger.debug("Completed extracting '{}' to dir: {}", entry.name, destDir) } - TEMPLATE_CORE_ARCHIVE -> { - val coreCgt = Environment.TEMPLATES_DIR.resolve(TEMPLATE_CORE_ARCHIVE) - coreCgt.outputStream().use { output -> - zipInput.copyTo(output) - } - } + LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> { + val destDir = destinationDirForArchiveEntry(entry.name).toPath() + if (Files.exists(destDir)) { + destDir.deleteRecursively() + } + Files.createDirectories(destDir) + // 1) harvested repo + AssetsInstallationHelper.extractZipToDir(zipInput, destDir) + // 2) plugin coordinate overlay from the split assets zip -- merged (no wipe) + val overlay = zipFile.getEntry(PLUGIN_MAVEN_REPO_ZIP_NAME) + if (overlay == null) { + throw FileNotFoundException( + context.getString(R.string.err_asset_entry_not_found, PLUGIN_MAVEN_REPO_ZIP_NAME), + ) + } + zipFile.getInputStream(overlay).use { overlayInput -> + AssetsInstallationHelper.extractZipToDir(overlayInput, destDir) + } + logger.debug("Merged plugin coordinates into {}", destDir) + } + + TEMPLATE_CORE_ARCHIVE -> { + val coreCgt = Environment.TEMPLATES_DIR.resolve(TEMPLATE_CORE_ARCHIVE) + coreCgt.outputStream().use { output -> + zipInput.copyTo(output) + } + } - AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> { + AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> { logger.debug("Extracting 'bootstrap.zip' to dir: {}", stagingDir) - val result = retryOnceOnNoSuchFile( - onFirstFailure = { Files.createDirectories(stagingDir) }, - onSecondFailure = { e2 -> - logger.error("Failed to open temporary bootstrap zip after retry", e2) - return@withContext - } - ) { - withTempZipChannel( - stagingDir = stagingDir, - prefix = "bootstrap", - writeTo = { path -> - zipFile.getInputStream(entry).use { freshZipInput -> - Files.newOutputStream(path).use { out -> - freshZipInput.copyTo(out) - } - } + val result = + retryOnceOnNoSuchFile( + onFirstFailure = { Files.createDirectories(stagingDir) }, + onSecondFailure = { e2 -> + logger.error("Failed to open temporary bootstrap zip after retry", e2) + return@withContext }, - useChannel = { ch -> - TerminalInstaller.installIfNeeded(context, ch) - } - ) - } + ) { + withTempZipChannel( + stagingDir = stagingDir, + prefix = "bootstrap", + writeTo = { path -> + zipFile.getInputStream(entry).use { freshZipInput -> + Files.newOutputStream(path).use { out -> + freshZipInput.copyTo(out) + } + } + }, + useChannel = { ch -> + TerminalInstaller.installIfNeeded(context, ch) + }, + ) + } if (result !is TerminalInstaller.InstallResult.Success) { logger.error("Failed to install terminal: {}", result) @@ -130,40 +155,45 @@ data object SplitAssetsInstaller : BaseAssetsInstaller() { } logger.debug("Completed extracting '{}' to {}", DOCUMENTATION_DB, Environment.DOC_DB) } - AssetsInstallationHelper.PLUGIN_ARTIFACTS_ZIP -> { - logger.debug("Extracting plugin artifacts from '{}'", entry.name) - val pluginDir = Environment.PLUGIN_API_JAR.parentFile - ?: throw IllegalStateException("Plugin API parent directory is null") - val pluginDirPath = pluginDir.toPath().toAbsolutePath().normalize() - if (Files.exists(pluginDirPath)) { - pluginDirPath.deleteRecursively() - } - Files.createDirectories(pluginDirPath) - - ZipInputStream(zipInput).use { pluginZip -> - var pluginEntry = pluginZip.nextEntry - while (pluginEntry != null) { - if (!pluginEntry.isDirectory) { - val targetPath = pluginDirPath.resolve(pluginEntry.name).normalize() - // Security check: prevent path traversal attacks - if (!targetPath.startsWith(pluginDirPath)) { - throw IllegalStateException( - "Zip entry '${pluginEntry.name}' would escape target directory" - ) - } - val targetFile = targetPath.toFile() - targetFile.parentFile?.mkdirs() - logger.debug("Extracting '{}' to {}", pluginEntry.name, targetFile) - targetFile.outputStream().use { output -> - pluginZip.copyTo(output) - } - } - pluginEntry = pluginZip.nextEntry - } - } - logger.debug("Completed extracting plugin artifacts") - } - else -> throw IllegalStateException("Unknown entry: $entryName") + + AssetsInstallationHelper.PLUGIN_ARTIFACTS_ZIP -> { + logger.debug("Extracting plugin artifacts from '{}'", entry.name) + val pluginDir = + Environment.PLUGIN_API_JAR.parentFile + ?: throw IllegalStateException("Plugin API parent directory is null") + val pluginDirPath = pluginDir.toPath().toAbsolutePath().normalize() + if (Files.exists(pluginDirPath)) { + pluginDirPath.deleteRecursively() + } + Files.createDirectories(pluginDirPath) + + ZipInputStream(zipInput).use { pluginZip -> + var pluginEntry = pluginZip.nextEntry + while (pluginEntry != null) { + if (!pluginEntry.isDirectory) { + val targetPath = pluginDirPath.resolve(pluginEntry.name).normalize() + // Security check: prevent path traversal attacks + if (!targetPath.startsWith(pluginDirPath)) { + throw IllegalStateException( + "Zip entry '${pluginEntry.name}' would escape target directory", + ) + } + val targetFile = targetPath.toFile() + targetFile.parentFile?.mkdirs() + logger.debug("Extracting '{}' to {}", pluginEntry.name, targetFile) + targetFile.outputStream().use { output -> + pluginZip.copyTo(output) + } + } + pluginEntry = pluginZip.nextEntry + } + } + logger.debug("Completed extracting plugin artifacts") + } + + else -> { + throw IllegalStateException("Unknown entry: $entryName") + } } } } @@ -186,28 +216,25 @@ data object SplitAssetsInstaller : BaseAssetsInstaller() { super.postInstall(context, stagingDir) } - override fun expectedSize(entryName: String): Long = when (entryName) { - GRADLE_DISTRIBUTION_ARCHIVE_NAME -> 137260932L - ANDROID_SDK_ZIP -> 286625871L - DOCUMENTATION_DB -> 224296960L - LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> 215389106L - AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> 456462823L - GRADLE_API_NAME_JAR_ZIP -> 46758608L - TEMPLATE_CORE_ARCHIVE -> 702001L - else -> 0L - } - - private fun destinationDirForArchiveEntry(entryName: String): File = + override fun expectedSize(entryName: String): Long = + when (entryName) { + GRADLE_DISTRIBUTION_ARCHIVE_NAME -> 137260932L + ANDROID_SDK_ZIP -> 286625871L + DOCUMENTATION_DB -> 224296960L + LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> 215389106L + AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> 456462823L + GRADLE_API_NAME_JAR_ZIP -> 46758608L + TEMPLATE_CORE_ARCHIVE -> 702001L + else -> 0L + } + + private fun destinationDirForArchiveEntry(entryName: String): File = when (entryName) { GRADLE_DISTRIBUTION_ARCHIVE_NAME -> Environment.GRADLE_DISTS ANDROID_SDK_ZIP -> Environment.ANDROID_HOME LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> Environment.LOCAL_MAVEN_DIR GRADLE_API_NAME_JAR_ZIP -> Environment.GRADLE_GEN_JARS - TEMPLATE_CORE_ARCHIVE -> Environment.TEMPLATES_DIR + TEMPLATE_CORE_ARCHIVE -> Environment.TEMPLATES_DIR else -> throw IllegalStateException("Entry '$entryName' is not expected to be an archive") } - } - - - diff --git a/app/src/test/java/com/itsaky/androidide/assets/ExtractZipToDirMergeTest.kt b/app/src/test/java/com/itsaky/androidide/assets/ExtractZipToDirMergeTest.kt new file mode 100644 index 0000000000..9f4ab7f879 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/assets/ExtractZipToDirMergeTest.kt @@ -0,0 +1,73 @@ +package com.itsaky.androidide.assets + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.nio.file.Files +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class ExtractZipToDirMergeTest { + // Real archives merged in production (e.g. plugin-maven-repo.zip, built by Gradle's + // Zip task -- verified via `unzip -l assets/plugin-maven-repo.zip`) always carry an + // explicit directory entry for every ancestor path. extractZipToDir relies on that + // (it only Files.createDirectories() on directory entries, not on every file + // entry's parent), so mirror that shape here rather than writing bare file entries. + private fun zipOf(vararg entries: Pair): ByteArrayInputStream { + val bos = ByteArrayOutputStream() + ZipOutputStream(bos).use { zip -> + val dirsWritten = LinkedHashSet() + for ((name, _) in entries) { + val parts = name.split("/").dropLast(1) + var prefix = "" + for (part in parts) { + prefix += "$part/" + if (dirsWritten.add(prefix)) { + zip.putNextEntry(ZipEntry(prefix)) + zip.closeEntry() + } + } + } + for ((name, body) in entries) { + zip.putNextEntry(ZipEntry(name)) + zip.write(body.toByteArray()) + zip.closeEntry() + } + } + return ByteArrayInputStream(bos.toByteArray()) + } + + @Test + fun `overlay merges without wiping existing files`() { + val dest = + Files.createTempDirectory("mvn").also { + Files.createDirectories(it.resolve("com/foo/1.0")) + Files.write(it.resolve("com/foo/1.0/foo-1.0.jar"), "harvested".toByteArray()) + } + + AssetsInstallationHelper.extractZipToDir( + zipOf("com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.jar" to "fat"), + dest, + ) + + assertTrue( + "harvested file must survive the merge", + Files.exists(dest.resolve("com/foo/1.0/foo-1.0.jar")), + ) + assertEquals( + "fat", + String(Files.readAllBytes(dest.resolve("com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.jar"))), + ) + } + + @Test + fun `rejects path traversal`() { + val dest = Files.createTempDirectory("mvn") + assertThrows(IllegalStateException::class.java) { + AssetsInstallationHelper.extractZipToDir(zipOf("../evil.jar" to "x"), dest) + } + } +} diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 212db5f4f8..dc01fac8e9 100755 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -9,6 +9,15 @@ android { namespace = "${BuildConfig.PACKAGE_NAME}.common" } +kotlin { + compilerOptions { + // This module's classes ship in the plugin-api coordinate that on-device plugins + // compile against, so emit metadata the on-device Kotlin (1.9.22) can read (<= 2.0.0). + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0) + } +} + dependencies { compileOnly(libs.composite.javac) diff --git a/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt b/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt index 2bd9cbdd3e..d7bc7f5694 100644 --- a/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt +++ b/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt @@ -63,6 +63,10 @@ const val LOCAL_MAVEN_REPO_FOLDER_DEST = "localMvnRepository" @Suppress("SdCardPath") const val MAVEN_LOCAL_REPOSITORY = "/data/data/com.itsaky.androidide/files/$LOCAL_MAVEN_CACHES_DEST/$LOCAL_MAVEN_REPO_FOLDER_DEST" +// Plugin maven-repo overlay (plugin-api + plugin-builder coordinates + marker) +const val PLUGIN_MAVEN_REPO_ZIP_NAME = "plugin-maven-repo.zip" +const val PLUGIN_MAVEN_REPO_ZIP_BR = "${PLUGIN_MAVEN_REPO_ZIP_NAME}.br" + // Tooltips const val CONTENT_KEY = "CONTENT_KEY" const val CONTENT_TITLE_KEY = "CONTENT_TITLE_KEY" diff --git a/composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/AndroidIDEAssetsPlugin.kt b/composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/AndroidIDEAssetsPlugin.kt index 72eb4f5288..fa69885a18 100644 --- a/composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/AndroidIDEAssetsPlugin.kt +++ b/composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/AndroidIDEAssetsPlugin.kt @@ -73,6 +73,9 @@ class AndroidIDEAssetsPlugin : Plugin { // Add plugin-artifacts.zip registerPluginArtifactsCopierTask(variant, variantNameCapitalized) + + // Add plugin-maven-repo.zip + registerPluginMavenRepoCopierTask(variant, variantNameCapitalized) } } } @@ -106,6 +109,35 @@ class AndroidIDEAssetsPlugin : Plugin { } } + private fun Project.registerPluginMavenRepoCopierTask( + variant: Variant, + variantName: String, + ) { + val taskName = "copy${variantName}PluginMavenRepo" + val projectPath = ":app" + val projectTask = "createPluginMavenRepoZip" + val inputFile: (Project) -> Provider = + { _ -> rootProject.providers.provider { rootProject.layout.projectDirectory.file("assets/plugin-maven-repo.zip") } } + + if (hasBundledAssets(variant)) { + addProjectArtifactToAssets( + variant = variant, + taskName = taskName, + projectPath = projectPath, + projectTask = projectTask, + onGetInputFile = inputFile, + ) + } else { + addProjectArtifactToAssets( + variant = variant, + taskName = taskName, + projectPath = projectPath, + projectTask = projectTask, + onGetInputFile = inputFile, + ) + } + } + private fun Project.registerInitScriptGeneratorTask( variant: Variant, variantName: String, diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 495d6bf912..9c5677868c 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -34,6 +34,16 @@ Legend: `added` = new capability, safe to adopt · `tooling` = API-stability milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed]** = diffed from `plugin-api/src` history (predates the dump; symbol-accurate). +### 26.31 — 2026-07-29 +- **tooling — Plugin API & builder resolvable by Maven coordinate on-device** _(ADFA-4911)_ + The plugin API and the builder Gradle plugin are injected into the on-device + local Maven repository at onboarding, so a plugin resolves them by coordinate, + offline, without committing `libs/*.jar`: + `compileOnly("com.itsaky.androidide:plugin-api:1.0.0")` and + `plugins { id("com.itsaky.androidide.plugins.build") version "1.0.0" }`. + `plugin-api:1.0.0` bundles `:plugin-api` + `common` + `eventbus-events` + + `idetooltips`. (No-`libs/` project detection lands separately in ADFA-4913.) + ### 26.30 — 2026-07-20 - **added — Project-search providers** _(ADFA-4723, `88c20f3`)_ **[verified]** Plugins contribute their own project-search sources that render as dedicated diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 3f23efbed2..7b7d4610fd 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -49,6 +49,50 @@ pluginBuilder { } ``` +### Depending on the plugin API + +The IDE-side API your plugin compiles against ships as a Maven **coordinate**, +injected into the on-device local Maven repository during onboarding — so it +resolves offline, with no `libs/*.jar` to commit: + +```kotlin +dependencies { + compileOnly("com.itsaky.androidide:plugin-api:1.0.0") +} +``` + +`plugin-api:1.0.0` is a single jar bundling the API surface plugins compile +against — the `:plugin-api` module plus `common`, `eventbus-events`, and +`idetooltips`. The builder plugin applied above resolves the same way, from the +injected `com.itsaky.androidide.plugins.build` `1.0.0` marker. + +#### Building on-device (offline) pins the AGP/Kotlin versions + +The `plugins {}` example above uses AGP `8.8.2` / Kotlin `2.1.21` — the versions the +dev/CI repo resolves online. A plugin built **on-device** resolves AGP and the Kotlin +Gradle plugin from the harvested on-device `localMvnRepository`, which currently ships +only **AGP `8.11.0`** and **Kotlin `1.9.22`**. Request those versions for an on-device +build, or offline resolution of the build plugins fails. + +Also declare AGP `apply false` in the **root** `build.gradle.kts` (as the standard CoGo +project template does): + +```kotlin +plugins { + id("com.android.application") apply false version "8.11.0" + id("com.android.library") apply false version "8.11.0" +} +``` + +CoGo injects `LogSenderPlugin` (via its build init script) into every module; that +plugin references AGP's `ApplicationVariant` and is loaded from the root buildscript +classpath, so a root without AGP fails configuration with +`NoClassDefFoundError: com/android/build/api/variant/ApplicationVariant`. + +> The in-app plugin-project detector still recognizes a project by a +> `libs/plugin-api.jar` on disk; dropping that on-disk requirement so a +> coordinate-only plugin is recognized is tracked in ADFA-4913. + ## AndroidManifest meta-data reference All plugin metadata is declared as `` tags inside diff --git a/docs/superpowers/plans/2026-07-28-inject-plugin-coordinates-localmvnrepository.md b/docs/superpowers/plans/2026-07-28-inject-plugin-coordinates-localmvnrepository.md new file mode 100644 index 0000000000..194c959950 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-inject-plugin-coordinates-localmvnrepository.md @@ -0,0 +1,601 @@ +# Inject plugin-api + builder coordinates into localMvnRepository — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** During CoGo onboarding, materialize plugin-api (fat compile jar), plugin-builder, and the `com.itsaky.androidide.plugins.build` marker into the on-device `localMvnRepository` as real Maven coordinates, so plugins resolve them by coordinate, offline, with no `libs/*.jar`. + +**Architecture:** Build-time, the host CoGo build assembles a small Maven-layout zip (`plugin-maven-repo.zip`): a fat `com.itsaky.androidide:plugin-api:1.0.0` jar (merged classes of plugin-api + common + eventbus-events + idetooltips, dependency-free POM) plus the builder impl + POM + marker emitted by real `maven-publish`. On-device, the two installers extract that zip into `LOCAL_MAVEN_DIR` **inside** the existing `localMvnRepository` branch (after its wipe+extract, via the non-wiping `extractZipToDir`) to avoid a wipe/concurrency race. + +**Tech Stack:** Gradle Kotlin DSL, `maven-publish` + `java-gradle-plugin`, AGP `com.android.library`, Kotlin, brotli4j, java.nio zip. Build wrapped in `flox activate -d flox/local -- ./gradlew`. + +## Global Constraints + +- **Build wrapper:** every Gradle call is `flox activate -d flox/local -- ./gradlew `. +- **Worktree:** work in `~/src/cogo/ADFA-4911` (branch `ADFA-4911-inject-plugin-jars-localmvn`); `app/google-services.json` already copied in. +- **Coordinates:** `com.itsaky.androidide:plugin-api:1.0.0` (jar), `com.itsaky.androidide.plugins:plugin-builder:1.0.0`, marker `com.itsaky.androidide.plugins.build:com.itsaky.androidide.plugins.build.gradle.plugin:1.0.0`. Version `1.0.0` everywhere. +- **Do NOT** add `plugin-maven-repo.zip` to `AssetsInstallationHelper.expectedEntries` — it must not become a concurrent install job (would race the `LOCAL_MAVEN_DIR` wipe). It is applied inside the `localMvnRepository` branch only. +- **Do NOT** touch the `plugin-artifacts.zip → .cg/plugin-api/` flow (still feeds `isPluginProject` until ADFA-4913) or the harvest pipeline. The plugin-api / common / eventbus-events / idetooltips module build files **are** edited — pinned to Kotlin `languageVersion`/`apiVersion` 2.0 so their metadata is readable by the on-device Kotlin 1.9.22 compiler. +- **Fat-jar harvest paths:** plugin-api `intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar`; the other three (v7/v8 flavored) `intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar`. +- **Code style:** tabs, LF; run `spotlessApply` before any commit that touches Kotlin/gradle.kts. Branch name already matches `ADFA-#####`. +- **Links:** the Maven POM `xmlns="http://maven.apache.org/POM/4.0.0"` is a standard XML **namespace identifier**, never dereferenced (no network) — it is required for a well-formed POM and is the one allowed http string. + +--- + +### Task 1: Publish plugin-builder to a build-dir Maven repo (impl POM + marker) + +**Files:** +- Modify: `plugin-api/plugin-builder/build.gradle.kts` + +**Interfaces:** +- Produces: a Maven layout under `plugin-api/plugin-builder/build/plugin-maven-repo/` containing + `com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.{jar,pom}` and + `com/itsaky/androidide/plugins/build/com.itsaky.androidide.plugins.build.gradle.plugin/1.0.0/*.pom`. +- Produces: publish task `publishAllPublicationsToPluginMavenRepoRepository` (referenced by Task 3). + +- [ ] **Step 1: Add `maven-publish`, a build-dir repo, and disable module metadata** + +Edit `plugin-api/plugin-builder/build.gradle.kts`: + +```kotlin +plugins { + `kotlin-dsl` + `maven-publish` +} + +group = "com.itsaky.androidide.plugins" +version = "1.0.0" + +dependencies { + // compileOnly so the published POM stays dependency-free; the on-device build + // provides AGP (agp-tooling 8.11.0, as shipped in localMvnRepository). + compileOnly("com.android.tools.build:gradle:8.11.0") +} + +gradlePlugin { + plugins { + create("pluginBuilder") { + id = "com.itsaky.androidide.plugins.build" + implementationClass = "com.itsaky.androidide.plugins.build.PluginBuilder" + displayName = "Code on the Go Plugin Builder" + description = "Gradle plugin for building Code on the Go plugins" + } + } +} + +publishing { + repositories { + maven { + name = "pluginMavenRepo" + url = uri(layout.buildDirectory.dir("plugin-maven-repo")) + } + } +} + +// Ship POMs only (parity with the harvested repo); marker/plugin resolution works off POMs. +tasks.withType().configureEach { enabled = false } + +tasks.withType { + compilerOptions { + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + } +} +``` + +`java-gradle-plugin` (auto-applied by `kotlin-dsl`) auto-creates the `pluginMaven` (impl) and `pluginBuilderPluginMarkerMaven` (marker) publications; `maven-publish` adds the `publishAllPublicationsToPluginMavenRepoRepository` task. + +- [ ] **Step 2: Run the publish task and confirm the exact task name** + +Run: `flox activate -d flox/local -- ./gradlew -p plugin-api/plugin-builder tasks --all | grep -i publish` +Expected: a line `publishAllPublicationsToPluginMavenRepoRepository`. If the name differs, use the actual name in Task 3. + +- [ ] **Step 3: Publish and inspect the output layout** + +Run: +```bash +flox activate -d flox/local -- ./gradlew -p plugin-api/plugin-builder publishAllPublicationsToPluginMavenRepoRepository +find plugin-api/plugin-builder/build/plugin-maven-repo -type f | sort +``` +Expected files (no `.module`): +``` +.../com/itsaky/androidide/plugins/build/com.itsaky.androidide.plugins.build.gradle.plugin/1.0.0/com.itsaky.androidide.plugins.build.gradle.plugin-1.0.0.pom +.../com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.jar +.../com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.pom +``` + +- [ ] **Step 4: Verify the POMs carry the right dependencies** + +Run: `grep -A3 -i "artifactId" plugin-api/plugin-builder/build/plugin-maven-repo/com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.pom` +Expected: the impl POM is **dependency-free** (AGP is `compileOnly`, so excluded from the published POM; the on-device build supplies it). The marker POM depends on `com.itsaky.androidide.plugins:plugin-builder:1.0.0`: +Run: `grep -i "plugin-builder" plugin-api/plugin-builder/build/plugin-maven-repo/com/itsaky/androidide/plugins/build/*/1.0.0/*.pom` +Expected: a `` on `plugin-builder` `1.0.0`. + +- [ ] **Step 5: Commit** + +```bash +cd ~/src/cogo/ADFA-4911 +flox activate -d flox/local -- ./gradlew spotlessApply +git add plugin-api/plugin-builder/build.gradle.kts +git commit -m "ADFA-4911: Publish plugin-builder (impl POM + Gradle plugin marker) to a build-dir maven repo" +``` + +--- + +### Task 2: Assemble the fat plugin-api jar + +**Files:** +- Modify: `app/build.gradle.kts` (add task near the existing `createPluginArtifactsZip`, ~L435) + +**Interfaces:** +- Produces: `app/build/plugin-maven-repo-staging/plugin-api-1.0.0.jar` — a jar containing the merged main classes of `:plugin-api`, `:common`, `:eventbus-events`, `:idetooltips`. + +- [ ] **Step 1: Register the fat-jar task** + +Add to `app/build.gradle.kts` (after `createPluginArtifactsZip`, before `createAssetsZip`): + +```kotlin +// Fat compile-only jar published as com.itsaky.androidide:plugin-api:1.0.0. +// Merges the API surface plugins already compile against (plugin-api + common + +// eventbus-events + idetooltips) into one coordinate. The three add-ons are +// v7/v8-flavored (unlike plugin-api); their classes are ABI-neutral so v8 is used. +tasks.register("assemblePluginApiFatJar") { + dependsOn( + ":plugin-api:assembleRelease", + ":common:assembleV8Release", + ":eventbus-events:assembleV8Release", + ":idetooltips:assembleV8Release", + ) + archiveFileName.set("plugin-api-1.0.0.jar") + destinationDirectory.set(layout.buildDirectory.dir("plugin-maven-repo-staging")) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + from(zipTree(project(":plugin-api").layout.buildDirectory + .file("intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar").get().asFile)) + from(zipTree(project(":common").layout.buildDirectory + .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar").get().asFile)) + from(zipTree(project(":eventbus-events").layout.buildDirectory + .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar").get().asFile)) + from(zipTree(project(":idetooltips").layout.buildDirectory + .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar").get().asFile)) +} +``` + +- [ ] **Step 2: Build the fat jar** + +Run: `flox activate -d flox/local -- ./gradlew :app:assemblePluginApiFatJar` +Expected: BUILD SUCCESSFUL; `app/build/plugin-maven-repo-staging/plugin-api-1.0.0.jar` exists. If a `classes.jar` path is wrong, the build fails on a missing zip input — fix the path (verify with `find /build/intermediates/aar_main_jar -name classes.jar`). + +- [ ] **Step 3: Verify the jar contains a class from each of the 4 modules** + +Run: +```bash +unzip -l app/build/plugin-maven-repo-staging/plugin-api-1.0.0.jar | \ + grep -E "com/itsaky/androidide/(plugins/api|common|eventbus|idetooltips)" | head +``` +Expected: at least one `.class` under each of the four package roots (`plugins/api`, `common`, `eventbus`, `idetooltips`). If any is missing, that module's `classes.jar` path is wrong. + +- [ ] **Step 4: Commit** + +```bash +cd ~/src/cogo/ADFA-4911 +flox activate -d flox/local -- ./gradlew spotlessApply +git add app/build.gradle.kts +git commit -m "ADFA-4911: Assemble fat plugin-api jar (plugin-api + common + eventbus-events + idetooltips)" +``` + +--- + +### Task 3: Write the plugin-api POM and assemble `plugin-maven-repo.zip` + +**Files:** +- Modify: `app/build.gradle.kts` (add `writePluginApiPom` + `createPluginMavenRepoZip` after Task 2's task) + +**Interfaces:** +- Consumes: Task 1's `publishAllPublicationsToPluginMavenRepoRepository`; Task 2's `assemblePluginApiFatJar`. +- Produces: `assets/plugin-maven-repo.zip` — a Maven layout with all three coordinates. + +- [ ] **Step 1: Register the POM writer and the zip assembler** + +Add to `app/build.gradle.kts` (after `assemblePluginApiFatJar`): + +```kotlin +// Dependency-free POM for the fat plugin-api coordinate: it is compile-only/provided, +// so it must NOT drag transitives that would need offline resolution. +tasks.register("writePluginApiPom") { + val pomFile = layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.pom") + outputs.file(pomFile) + doLast { + pomFile.get().asFile.writeText( + """ + + 4.0.0 + com.itsaky.androidide + plugin-api + 1.0.0 + jar + +""", + ) + } +} + +// Assembles the shippable Maven layout: the fat plugin-api coordinate + the +// builder impl/POM/marker published by the plugin-builder included build. +tasks.register("createPluginMavenRepoZip") { + dependsOn("assemblePluginApiFatJar", "writePluginApiPom") + dependsOn(gradle.includedBuild("plugin-builder") + .task(":publishAllPublicationsToPluginMavenRepoRepository")) + + archiveFileName.set("plugin-maven-repo.zip") + destinationDirectory.set(rootProject.file("assets")) + + into("com/itsaky/androidide/plugin-api/1.0.0") { + from(layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.jar")) + from(layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.pom")) + } + // Builder tree is already in Maven layout (com/itsaky/androidide/plugins/...). + from(rootProject.file("plugin-api/plugin-builder/build/plugin-maven-repo")) +} +``` + +- [ ] **Step 2: Build the zip** + +Run: `flox activate -d flox/local -- ./gradlew :app:createPluginMavenRepoZip` +Expected: BUILD SUCCESSFUL; `assets/plugin-maven-repo.zip` exists. + +- [ ] **Step 3: Verify the coordinate layout inside the zip** + +Run: `unzip -l assets/plugin-maven-repo.zip | grep -E "1.0.0/" | sort` +Expected exactly these artifact paths (order aside): +``` +com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.jar +com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.pom +com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.jar +com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.pom +com/itsaky/androidide/plugins/build/com.itsaky.androidide.plugins.build.gradle.plugin/1.0.0/com.itsaky.androidide.plugins.build.gradle.plugin-1.0.0.pom +``` + +- [ ] **Step 4: Commit** + +```bash +cd ~/src/cogo/ADFA-4911 +flox activate -d flox/local -- ./gradlew spotlessApply +git add app/build.gradle.kts +git commit -m "ADFA-4911: Assemble plugin-maven-repo.zip (plugin-api coordinate + builder + marker)" +``` + +--- + +### Task 4: Register `plugin-maven-repo.zip` as a shipped asset (bundled `.br` + split zip) + +**Files:** +- Modify: `composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt` +- Modify: `composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/AndroidIDEAssetsPlugin.kt` +- Modify: `app/build.gradle.kts` (`createAssetsZip` file list ~L455-464; `assembleV8Assets`/`assembleV7Assets` deps ~L486-503) + +**Interfaces:** +- Consumes: Task 3's `assets/plugin-maven-repo.zip`. +- Produces: constant `PLUGIN_MAVEN_REPO_ZIP_NAME = "plugin-maven-repo.zip"` and `PLUGIN_MAVEN_REPO_ZIP_BR`; bundled common asset `data/common/plugin-maven-repo.zip.br`; split entry `plugin-maven-repo.zip` inside `assets-.zip`. (Task 5 consumes these.) + +- [ ] **Step 1: Add the asset-name constants** + +In `constants.kt`, after the Local-maven-repo block (`LOCAL_MAVEN_REPO_FOLDER_DEST`, ~L61): + +```kotlin +// Plugin maven-repo overlay (plugin-api + plugin-builder coordinates + marker) +const val PLUGIN_MAVEN_REPO_ZIP_NAME = "plugin-maven-repo.zip" +const val PLUGIN_MAVEN_REPO_ZIP_BR = "${PLUGIN_MAVEN_REPO_ZIP_NAME}.br" +``` + +- [ ] **Step 2: Register the per-build brotli copier for bundled builds** + +In `AndroidIDEAssetsPlugin.kt`, mirror `registerPluginArtifactsCopierTask` (~L80-107) with a new function, and call it from the `onVariants` block (after the plugin-artifacts copier registration, ~L75). The copier brotli-compresses `assets/plugin-maven-repo.zip` into `data/common/plugin-maven-repo.zip.br` when `hasBundledAssets(variant)`: + +```kotlin +private fun registerPluginMavenRepoCopierTask( + project: Project, + variant: Variant, +) { + val zip = project.rootProject.file("assets/plugin-maven-repo.zip") + val taskName = "copy${variant.name.replaceFirstChar { it.uppercase() }}PluginMavenRepo" + if (hasBundledAssets(variant)) { + val task = project.tasks.register(taskName, AddBrotliFileToAssetsTask::class.java) { + it.dependsOn(project.tasks.named("createPluginMavenRepoZip")) + it.inputFile.set(zip) + } + variant.sources.assets?.addGeneratedSourceDirectory(task, AddBrotliFileToAssetsTask::outputDirectory) + } else { + val task = project.tasks.register(taskName, AddFileToAssetsTask::class.java) { + it.dependsOn(project.tasks.named("createPluginMavenRepoZip")) + it.inputFile.set(zip) + } + variant.sources.assets?.addGeneratedSourceDirectory(task, AddFileToAssetsTask::outputDirectory) + } +} +``` + +Match the exact wiring of `registerPluginArtifactsCopierTask` (task property names, `baseAssetPath`/`data/common` default, `onVariants` call site). Call `registerPluginMavenRepoCopierTask(project, variant)` alongside the existing copier calls in `onVariants`. + +- [ ] **Step 3: Add the split entry + assemble deps in `app/build.gradle.kts`** + +In `createAssetsZip(arch)`, add `"plugin-maven-repo.zip"` to the `arrayOf(...)` file list (after `"plugin-artifacts.zip"`, ~L462). No `entryName` remap is needed (the `when` at ~L471 falls through to `else -> fileName`), so the entry name stays `plugin-maven-repo.zip`. + +Add a `dependsOn("createPluginMavenRepoZip")` to both `assembleV8Assets` and `assembleV7Assets` (~L486-503), so the file exists before `createAssetsZip` runs (it throws `FileNotFoundException` on a missing file, ~L466-468). + +- [ ] **Step 4: Verify the split asset packaging includes the new entry** + +Run: `flox activate -d flox/local -- ./gradlew :app:assembleV8Assets` +Then: `unzip -l app/build/outputs/assets/assets-arm64-v8a.zip | grep plugin-maven-repo` +Expected: `plugin-maven-repo.zip` is listed as an entry. + +- [ ] **Step 5: Commit** + +```bash +cd ~/src/cogo/ADFA-4911 +flox activate -d flox/local -- ./gradlew spotlessApply +git add composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt \ + composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/AndroidIDEAssetsPlugin.kt \ + app/build.gradle.kts +git commit -m "ADFA-4911: Ship plugin-maven-repo.zip as a bundled (.br) and split asset" +``` + +--- + +### Task 5: Merge the overlay into LOCAL_MAVEN_DIR on-device (both installers) + +**Files:** +- Test: `app/src/test/java/com/itsaky/androidide/assets/ExtractZipToDirMergeTest.kt` (new) +- Modify: `app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt` (~L56-71) +- Modify: `app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt` (~L62-75) + +**Interfaces:** +- Consumes: `AssetsInstallationHelper.extractZipToDir(srcStream, destDir)` (existing, L241-271 — creates dirs and copies without wiping); constants `PLUGIN_MAVEN_REPO_ZIP_NAME`, `PLUGIN_MAVEN_REPO_ZIP_BR`; `ToolsManager.getCommonAsset` (prefixes `data/common/`). + +- [ ] **Step 1: Write the failing merge test** + +`extractZipToDir` is the merge primitive: it must add overlay entries into a dir that already has files, without deleting the existing ones, and reject path traversal. Create `ExtractZipToDirMergeTest.kt`: + +```kotlin +package com.itsaky.androidide.assets + +import io.mockk.mockkObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.nio.file.Files +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class ExtractZipToDirMergeTest { + @Before + fun setup() { + mockkObject(AssetsInstallationHelper) + } + + private fun zipOf(vararg entries: Pair): ByteArrayInputStream { + val bos = ByteArrayOutputStream() + ZipOutputStream(bos).use { zip -> + for ((name, body) in entries) { + zip.putNextEntry(ZipEntry(name)) + zip.write(body.toByteArray()) + zip.closeEntry() + } + } + return ByteArrayInputStream(bos.toByteArray()) + } + + @Test + fun `overlay merges without wiping existing files`() { + val dest = Files.createTempDirectory("mvn").also { + Files.createDirectories(it.resolve("com/foo/1.0")) + Files.writeString(it.resolve("com/foo/1.0/foo-1.0.jar"), "harvested") + } + + AssetsInstallationHelper.extractZipToDir( + zipOf("com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.jar" to "fat"), + dest, + ) + + assertTrue("harvested file must survive the merge", + Files.exists(dest.resolve("com/foo/1.0/foo-1.0.jar"))) + assertEquals("fat", + Files.readString(dest.resolve("com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.jar"))) + } + + @Test + fun `rejects path traversal`() { + val dest = Files.createTempDirectory("mvn") + assertThrows(IllegalStateException::class.java) { + AssetsInstallationHelper.extractZipToDir(zipOf("../evil.jar" to "x"), dest) + } + } +} +``` + +- [ ] **Step 2: Run the test to confirm it passes against the existing primitive** + +Run: `flox activate -d flox/local -- ./gradlew :app:testV8DebugUnitTest --tests "com.itsaky.androidide.assets.ExtractZipToDirMergeTest"` +Expected: PASS both cases. (This pins the merge/no-wipe + traversal-guard contract the installers rely on. `extractZipToDir` already enforces the `..`/absolute-path check at L251-253.) + +- [ ] **Step 3: Add the overlay to `BundledAssetsInstaller`** + +Split `LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME` out of the shared archive arm (L56-71) into its own branch that extracts the harvested repo, then merges the plugin overlay in the same job: + +```kotlin +GRADLE_DISTRIBUTION_ARCHIVE_NAME, +ANDROID_SDK_ZIP, +-> { + val destDir = destinationDirForArchiveEntry(entryName).toPath() + if (Files.exists(destDir)) { + destDir.deleteRecursively() + } + Files.createDirectories(destDir) + val assetPath = ToolsManager.getCommonAsset("$entryName.br") + assets.open(assetPath).use { assetStream -> + BrotliInputStream(assetStream).use { srcStream -> + AssetsInstallationHelper.extractZipToDir(srcStream, destDir) + } + } +} + +LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> { + val destDir = destinationDirForArchiveEntry(entryName).toPath() + if (Files.exists(destDir)) { + destDir.deleteRecursively() + } + Files.createDirectories(destDir) + // 1) harvested repo + assets.open(ToolsManager.getCommonAsset("$entryName.br")).use { assetStream -> + BrotliInputStream(assetStream).use { srcStream -> + AssetsInstallationHelper.extractZipToDir(srcStream, destDir) + } + } + // 2) plugin coordinate overlay -- merged (no wipe) into the same repo + assets.open(ToolsManager.getCommonAsset(PLUGIN_MAVEN_REPO_ZIP_BR)).use { assetStream -> + BrotliInputStream(assetStream).use { srcStream -> + AssetsInstallationHelper.extractZipToDir(srcStream, destDir) + } + } + logger.debug("Merged plugin coordinates into {}", destDir) +} +``` + +Add imports: `import org.adfa.constants.PLUGIN_MAVEN_REPO_ZIP_BR`. + +- [ ] **Step 4: Add the overlay to `SplitAssetsInstaller`** + +Split `LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME` out of the shared arm (L62-75). Extract the harvested repo from the entry stream, then read the `plugin-maven-repo.zip` entry from the already-open `zipFile` and merge: + +```kotlin +GRADLE_DISTRIBUTION_ARCHIVE_NAME, +ANDROID_SDK_ZIP, +GRADLE_API_NAME_JAR_ZIP, +-> { + val destDir = destinationDirForArchiveEntry(entry.name).toPath() + if (Files.exists(destDir)) { + destDir.deleteRecursively() + } + Files.createDirectories(destDir) + AssetsInstallationHelper.extractZipToDir(zipInput, destDir) +} + +LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> { + val destDir = destinationDirForArchiveEntry(entry.name).toPath() + if (Files.exists(destDir)) { + destDir.deleteRecursively() + } + Files.createDirectories(destDir) + // 1) harvested repo + AssetsInstallationHelper.extractZipToDir(zipInput, destDir) + // 2) plugin coordinate overlay from the split assets zip -- merged (no wipe) + val overlay = zipFile.getEntry(PLUGIN_MAVEN_REPO_ZIP_NAME) + ?: throw FileNotFoundException( + context.getString(R.string.err_asset_entry_not_found, PLUGIN_MAVEN_REPO_ZIP_NAME)) + zipFile.getInputStream(overlay).use { overlayInput -> + AssetsInstallationHelper.extractZipToDir(overlayInput, destDir) + } + logger.debug("Merged plugin coordinates into {}", destDir) +} +``` + +Add imports: `import org.adfa.constants.PLUGIN_MAVEN_REPO_ZIP_NAME`. (`GRADLE_API_NAME_JAR_ZIP` stays in the shared arm; only `LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME` moves out.) + +- [ ] **Step 5: Build both installers' module to confirm compilation** + +Run: `flox activate -d flox/local -- ./gradlew :app:compileV8DebugKotlin` +Expected: BUILD SUCCESSFUL (constants resolve, imports correct). + +- [ ] **Step 6: Commit** + +```bash +cd ~/src/cogo/ADFA-4911 +flox activate -d flox/local -- ./gradlew spotlessApply +git add app/src/test/java/com/itsaky/androidide/assets/ExtractZipToDirMergeTest.kt \ + app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt \ + app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt +git commit -m "ADFA-4911: Merge plugin coordinate overlay into localMvnRepository during onboarding" +``` + +--- + +### Task 6: Document the coordinate + version + +**Files:** +- Modify: the Plugin API changelog added by ADFA-1713 (find with `git log --oneline | grep -i changelog`, or `find . -iname "*plugin*api*changelog*" -o -iname "CHANGELOG*" -path "*plugin*"`), or `plugin-api/README.md` if no changelog exists. + +**Interfaces:** none (docs). + +- [ ] **Step 1: Add the coordinate + build snippet** + +Document that on-device plugins resolve, offline, with no `libs/`: + +```kotlin +plugins { + id("com.itsaky.androidide.plugins.build") version "1.0.0" +} +dependencies { + compileOnly("com.itsaky.androidide:plugin-api:1.0.0") +} +``` + +Note the `plugin-api:1.0.0` coordinate is a fat jar (plugin-api + common + eventbus-events + idetooltips), injected into `localMvnRepository` at onboarding, and its version tracks the shipped jar. + +- [ ] **Step 2: Commit** + +```bash +cd ~/src/cogo/ADFA-4911 +git add +git commit -m "ADFA-4911: Document the plugin-api:1.0.0 coordinate and coordinate-based plugin build" +``` + +--- + +### Task 7: End-to-end on-device verification (acceptance criteria) + +**Files:** none (verification only). Requires an arm device/emulator (`adb devices -l | grep -v offline`; target `emulator-5554`). + +- [ ] **Step 1: Build + install the debug APK and its split assets** + +```bash +flox activate -d flox/local -- ./gradlew :app:assembleV8Debug :app:assembleV8Assets --parallel --max-workers=6 +adb -s emulator-5554 install -r app/build/outputs/apk/v8/debug/app-v8-debug.apk +adb -s emulator-5554 push app/build/outputs/assets/assets-arm64-v8a.zip /sdcard/Download/assets-arm64-v8a.zip +``` +Then launch the app and complete onboarding (asset installation). + +- [ ] **Step 2: Verify the coordinates landed (AC #1)** + +```bash +adb -s emulator-5554 shell "find /data/data/com.itsaky.androidide/files/home/maven/localMvnRepository -path '*plugin*' -name '*.pom' -o -path '*plugin*' -name '*.jar'" +``` +Expected: the plugin-api jar+pom, plugin-builder jar+pom, and the `com.itsaky.androidide.plugins.build` marker pom, at their coordinate paths. + +- [ ] **Step 3: Build a no-`libs/` plugin offline (AC #2)** + +On-device (or via a Termux/gradle harness), create a minimal plugin project with **no** `libs/` dir: +```kotlin +// settings.gradle.kts resolves via COTGSettingsPlugin (localMvnRepository injected) +plugins { id("com.itsaky.androidide.plugins.build") version "1.0.0" } +dependencies { compileOnly("com.itsaky.androidide:plugin-api:1.0.0") } +``` +Run `:assemblePluginDebug` with networking disabled. Expected: BUILD SUCCESSFUL, a `.cgp` produced, no network access. + +- [ ] **Step 4: Record results on the Jira ticket** + +`jira issue comment add ADFA-4911 ""` + +--- + +## Self-Review + +**Spec coverage:** the 3 coordinates (Task 1-3), fat-jar merge of all 4 modules (Task 2), dependency-free plugin-api POM + real builder POM/marker (Tasks 1,3), sibling-asset shipping bundled+split (Task 4), the wipe/concurrency-safe overlay inside the localMvnRepository branch (Task 5), the merge/traversal test (Task 5), docs (Task 6), and all three acceptance criteria (Task 7). No spec requirement is unmapped. + +**Placeholders:** none — every code/test/command step is concrete. The two empirically-risky names (the builder publish-task name; the `classes.jar` intermediate paths) each have an explicit discover/verify step (1.2, 2.2/2.3) that fails loudly on mismatch. + +**Type/name consistency:** constant names `PLUGIN_MAVEN_REPO_ZIP_NAME` / `PLUGIN_MAVEN_REPO_ZIP_BR` are defined in Task 4 and consumed by the split/bundled branches in Task 5; the coordinate paths asserted in 3.3 match those verified on-device in 7.2; `extractZipToDir` signature matches its existing definition. diff --git a/docs/superpowers/specs/2026-07-28-inject-plugin-coordinates-localmvnrepository-design.md b/docs/superpowers/specs/2026-07-28-inject-plugin-coordinates-localmvnrepository-design.md new file mode 100644 index 0000000000..2738af3611 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-inject-plugin-coordinates-localmvnrepository-design.md @@ -0,0 +1,181 @@ +# Inject plugin-api + builder coordinates into the on-device localMvnRepository + +**Ticket:** ADFA-4911 (blocks ADFA-4693; relates to ADFA-4908, ADFA-4913) +**Branch:** `ADFA-4911-inject-plugin-jars-localmvn` +**Date:** 2026-07-28 + +## Goal + +During Code On The Go onboarding, materialize the plugin build artifacts into the +freshly-unpacked on-device local Maven repository (`Environment.LOCAL_MAVEN_DIR` = +`$HOME/maven/localMvnRepository`) as proper Maven coordinates (jars + POMs + the Gradle +plugin marker). Plugin projects then resolve the plugin API and the +`com.itsaky.androidide.plugins.build` plugin **by coordinate** — offline, on-device, with +**no** per-plugin `libs/*.jar` files. + +This unblocks building plugins inside CoGo at scale without committing per-plugin jar +copies, and is a prerequisite for ADFA-4693. + +## Why the plumbing already works (verified against `stage`) + +- `COTGSettingsPlugin` injects the local repo into **both** resolution scopes + (`gradle-plugin/src/main/java/com/itsaky/androidide/gradle/COTGSettingsPlugin.kt:46-48`): + ```kotlin + dependencyResolutionManagement.repositories.addLocalRepos(allLocalRepos) // compileOnly coordinates + pluginManagement.repositories.addLocalRepos(allLocalRepos) // plugins { id() } markers + ``` + The always-present repo is `MAVEN_LOCAL_REPOSITORY` (`org.adfa.constants`, + `constants.kt:64`) = `/data/data/com.itsaky.androidide/files/home/maven/localMvnRepository`, + the exact directory `Environment.LOCAL_MAVEN_DIR` resolves to on-device + (`common/src/main/java/com/itsaky/androidide/utils/Environment.java:187`). +- `plugin-builder` is already a real Gradle plugin — `group=com.itsaky.androidide.plugins`, + `version=1.0.0`, id `com.itsaky.androidide.plugins.build` + (`plugin-api/plugin-builder/build.gradle.kts:5-19`). It is simply never published. +- Onboarding already unpacks the flat jars: both installers extract + `plugin-artifacts.zip` into `Environment.PLUGIN_API_JAR.parentFile` (= `.cg/plugin-api/`). + +## Why at onboarding (not baked into localMvnRepository.zip) + +`localMvnRepository.zip` is a *harvested* asset — scraped from the Gradle cache after CoGo +builds its templates online. The plugin-api/builder jars are never downloaded during those +builds, so a harvest would never include them. Injecting at onboarding reuses the +already-shipped plugin build outputs as the single source of truth, keeps versions in sync, +and leaves the fragile harvest pipeline untouched. + +## Verified wrinkles that shaped the design + +1. **`plugin-api` is a curated API module** — `com.android.library` with a + binary-compatibility validator (`plugin-api/build.gradle.kts:31-34`) and + `@InternalPluginApi` markers. Its jar is *scraped* from + `intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar` by the + `createPluginApiJar` Copy task (L48-53); `1.0.0` exists only as a rename string. It has + **no** group/version/publishing. Physically merging other modules *into* this module + would pollute its locked API surface — so we merge at the **packaging** layer instead. +2. **`common`, `eventbus-events`, `idetooltips`** are `com.android.library` modules. Unlike + plugin-api, `configureAndroidModule` (`AndroidModuleConf.kt:232-249`, applied to every + Android library **except** `:plugin-api`) injects `v7`/`v8` product flavors, so their + compiled classes land at flavor-qualified paths + (`intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar`, task + `assembleV8Release`). The fat jar harvests the **v8** flavor for these three — their + classes are ABI-neutral API (the only flavor difference is `BuildConfig.ABI_*` strings, + which plugins never compile against), so one v8 harvest is safe for the single shared + asset. plugin-api stays flavorless (`.../release/syncReleaseLibJars/classes.jar`). None + of the three has group/version/publishing. +3. **`plugin-builder` applies `maven-publish`** (to emit the impl POM + Gradle plugin + marker) and is a separate **included build**, consumed via + `gradle.includedBuild("plugin-builder")`. `plugin-api` does not publish — its fat jar + and POM are assembled in `app/build.gradle.kts`. +4. **Both shipped POMs are dependency-free.** The compile-only API jar carries no + transitives; the builder declares AGP as `compileOnly` (provided on-device by + agp-tooling 8.11.0, as shipped in `localMvnRepository`), so it is excluded from the + published POM. Forcing AGP as a transitive would make the coordinate unresolvable + offline whenever the harvested AGP differs from a pinned version. This matches today's + reality — plugins already compile against flat jars with no transitives. +5. In-repo example plugins use `compileOnly(project(":plugin-api"))`, not `libs/*.jar`; the + five-flat-jars pattern lives in the external plugin-examples repo (ADFA-4908's concern). + +## Chosen approach: fat compile-jar + published builder, shipped as a sibling asset + +### Coordinates shipped (3) + +| Coordinate | Contents | POM | +|---|---|---| +| `com.itsaky.androidide:plugin-api:1.0.0` | **Fat jar** = merged `classes.jar` of plugin-api + common + eventbus-events + idetooltips | dependency-free (`packaging=jar`) | +| `com.itsaky.androidide.plugins:plugin-builder:1.0.0` | builder impl jar | real config-time deps (maven-publish generated) | +| `com.itsaky.androidide.plugins.build:com.itsaky.androidide.plugins.build.gradle.plugin:1.0.0` | Gradle plugin **marker** | depends on the impl (auto-generated) | + +**Decisions:** coordinate name stays `plugin-api` (satisfies the AC line verbatim; it is +what plugins already expect). Version `1.0.0` across the board. Gradle Module Metadata +(`.module`) disabled on the builder so only jars + POMs ship (parity with the harvested +repo; marker/plugin resolution works off POMs alone). The 3 add-on classes are harvested +from the existing `aar_main_jar` intermediate — **no** new files in those modules. + +### Build-time assembly (host CoGo build) + +All new logic lives in `app/build.gradle.kts`, plus one publishing block in +`plugin-api/plugin-builder/build.gradle.kts`. + +1. **`assemblePluginApiFatJar`** (new `Jar` task): `dependsOn` `:plugin-api:assembleRelease` + + `:common:assembleV8Release`, `:eventbus-events:assembleV8Release`, + `:idetooltips:assembleV8Release`; merges each module's `classes.jar` via + `from(zipTree(...))` (plugin-api at `.../release/syncReleaseLibJars/`, the other three at + `.../v8Release/syncV8ReleaseLibJars/`); `duplicatesStrategy = EXCLUDE`. Produces + `plugin-api-1.0.0.jar` (fat). + - Per-module `.kotlin_module` files carry distinct names; `R`/`BuildConfig` live in + distinct package namespaces — no collisions across first-party modules. +2. **Static POM** for plugin-api: a dependency-free `packaging=jar` POM written into the + Maven layout. Hand-writing this is safe (the no-transitives hazard applies only to the + builder). +3. **`plugin-builder/build.gradle.kts`**: add `` `maven-publish` `` and a + `maven { url = layout.buildDirectory.dir("plugin-maven-repo") }` publishing repo. + `java-gradle-plugin` (auto-applied by `kotlin-dsl`) emits the impl publication **and** + the marker. Disable `GenerateModuleMetadata`. App wires a `dependsOn` on the builder's + `publish…ToPluginRepoRepository` task via `gradle.includedBuild("plugin-builder")`. +4. **`createPluginMavenRepoZip`** (new `Zip`): merges the fat jar + static POM + the + builder's published tree into a single `com/itsaky/androidide/…` Maven layout → + `assets/plugin-maven-repo.zip`. Wire the new asset into the per-arch bundling + (`createAssetsZip(arch)`) and the common-asset list (`AndroidIDEAssetsPlugin.kt`). + +### On-device merge (onboarding) + +**Verified hazard:** the generic archive branch **wipes** its destination first +(`destDir.deleteRecursively()`, BundledAssetsInstaller.kt:61-63 / SplitAssetsInstaller.kt: +68-70), and all entries install **concurrently** (`async` + `joinAll`, +AssetsInstallationHelper.kt:160-174). A naive second entry targeting `LOCAL_MAVEN_DIR` +would race the `localMvnRepository.zip` wipe and be destroyed. + +**Chosen mechanism — apply the overlay inside the existing localMvnRepository branch:** + +- Add a `PLUGIN_MAVEN_REPO_ZIP = "plugin-maven-repo.zip"` asset constant (+ the `.br` + variant name), and ship the asset (bundled common `.br` + inside the split + `assets-.zip`). **Do NOT** add it to `expectedEntries` — it must not be a separate + concurrent job. +- In **both** installers, give `LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME` its own branch that: + (1) wipes + extracts the harvested `localMvnRepository.zip` into `LOCAL_MAVEN_DIR` as + today, then (2) in the **same** job, reads `plugin-maven-repo.zip` and extracts it into + the same dir via `AssetsInstallationHelper.extractZipToDir` (which creates dirs and + copies **without** wiping — a true merge). Bundled reads the `.br` common asset through + `BrotliInputStream`; split reads the `plugin-maven-repo.zip` entry from the already-open + `zipFile`. Because the overlay runs sequentially after the wipe within the one + localMvnRepository job, there is no race and no separate entry. +- The existing `plugin-artifacts.zip → .cg/plugin-api/` branch is untouched (still feeds + `isPluginProject`'s `libs/plugin-api.jar` check until ADFA-4913). + +## Out of scope (separate tickets) + +- Changing plugin detection from the `libs/plugin-api.jar` file check to the manifest + `plugin.id` meta-data (`ProjectValidations.isPluginProject`) — ADFA-4913. +- Migrating the external plugin-examples plugins to coordinate-based references + a + committed CI `maven-repo/` — ADFA-4908. + +## Testing & verification + +- **Installer unit test:** extract a fixture `plugin-maven-repo.zip` into a temp + `LOCAL_MAVEN_DIR`; assert the 3 coordinate paths land, a pre-existing harvested-style + entry survives the merge, and a `../evil` traversal entry is rejected. +- **Build test:** unzip the produced `plugin-maven-repo.zip`; assert the exact + coordinate / POM / marker paths exist and the fat jar contains a class from each of the + 4 source modules. +- **On-device (manual/instrumented):** a minimal plugin with **no** `libs/` directory, + using `plugins { id("com.itsaky.androidide.plugins.build") version "1.0.0" }` + + `compileOnly("com.itsaky.androidide:plugin-api:1.0.0")`, builds `:assemblePluginDebug` + fully offline → produces a `.cgp`. + +## Acceptance criteria (from ticket) + +- On a fresh CoGo install, `localMvnRepository` contains plugin-api, plugin-builder, and + the `com.itsaky.androidide.plugins.build` plugin marker at the correct coordinates + (verify by `find` on device). +- A plugin project with **no** `libs/` directory, using the builder plugin + `compileOnly` + plugin-api, builds `:assemblePluginDebug` successfully on-device, fully offline, + producing a `.cgp`. +- The plugin-api coordinate version (`1.0.0`) is documented and matches the shipped jar. + +## Files touched (net) + +`plugin-api/plugin-builder/build.gradle.kts`, `plugin-api/build.gradle.kts`, +`common/build.gradle.kts`, `eventbus-events/build.gradle.kts`, `idetooltips/build.gradle.kts` +(the last four: Kotlin `languageVersion`/`apiVersion` 2.0 pins), `app/build.gradle.kts`, the +asset-name constants (`org/adfa/constants/constants.kt`), the common-asset brotli registration +(`AndroidIDEAssetsPlugin`), `BundledAssetsInstaller.kt`, `SplitAssetsInstaller.kt`, docs. +**Untouched:** the `plugin-artifacts.zip → .cg/plugin-api/` flow and the harvest pipeline. diff --git a/eventbus-events/build.gradle.kts b/eventbus-events/build.gradle.kts index ab62bdeea3..ac7acb7999 100644 --- a/eventbus-events/build.gradle.kts +++ b/eventbus-events/build.gradle.kts @@ -26,6 +26,15 @@ android { namespace = "${BuildConfig.PACKAGE_NAME}.eventbus.events" } +kotlin { + compilerOptions { + // This module's classes ship in the plugin-api coordinate that on-device plugins + // compile against, so emit metadata the on-device Kotlin (1.9.22) can read (<= 2.0.0). + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0) + } +} + dependencies { implementation(libs.common.kotlin) implementation(projects.shared) diff --git a/idetooltips/build.gradle.kts b/idetooltips/build.gradle.kts index 0ad7782fb0..4716486943 100644 --- a/idetooltips/build.gradle.kts +++ b/idetooltips/build.gradle.kts @@ -10,6 +10,15 @@ android { namespace = "${BuildConfig.PACKAGE_NAME}.idetooltips" } +kotlin { + compilerOptions { + // This module's classes ship in the plugin-api coordinate that on-device plugins + // compile against, so emit metadata the on-device Kotlin (1.9.22) can read (<= 2.0.0). + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0) + } +} + dependencies { kapt(libs.room.compiler) diff --git a/plugin-api/build.gradle.kts b/plugin-api/build.gradle.kts index c7dcfd4571..a7943ce23c 100644 --- a/plugin-api/build.gradle.kts +++ b/plugin-api/build.gradle.kts @@ -22,9 +22,10 @@ android { kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) - // Pin to match the on-device Kotlin compiler - apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + // Emit metadata the on-device Kotlin compiler (1.9.22) can read (<= 2.0.0). + // This jar ships in the plugin-api coordinate on-device plugins compile against. + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0) } } diff --git a/plugin-api/plugin-builder/build.gradle.kts b/plugin-api/plugin-builder/build.gradle.kts index 65546509bc..f620f80344 100644 --- a/plugin-api/plugin-builder/build.gradle.kts +++ b/plugin-api/plugin-builder/build.gradle.kts @@ -1,28 +1,46 @@ plugins { - `kotlin-dsl` + `kotlin-dsl` + `maven-publish` } group = "com.itsaky.androidide.plugins" version = "1.0.0" dependencies { - implementation("com.android.tools.build:gradle:8.8.2") + // AGP is provided at runtime by the plugin project's own `com.android.application`, + // and on-device plugin builds use the tooling AGP (`agp-tooling` = 8.11.0), which is + // what the harvested localMvnRepository ships. Keep it compileOnly so the published + // POM stays dependency-free: forcing it as a transitive would make the coordinate + // unresolvable offline whenever the harvested AGP differs from a pinned version. + compileOnly("com.android.tools.build:gradle:8.11.0") } gradlePlugin { - plugins { - create("pluginBuilder") { - id = "com.itsaky.androidide.plugins.build" - implementationClass = "com.itsaky.androidide.plugins.build.PluginBuilder" - displayName = "Code on the Go Plugin Builder" - description = "Gradle plugin for building Code on the Go plugins" - } - } + plugins { + create("pluginBuilder") { + id = "com.itsaky.androidide.plugins.build" + implementationClass = "com.itsaky.androidide.plugins.build.PluginBuilder" + displayName = "Code on the Go Plugin Builder" + description = "Gradle plugin for building Code on the Go plugins" + } + } } +publishing { + repositories { + maven { + name = "pluginMavenRepo" + url = uri(layout.buildDirectory.dir("plugin-maven-repo")) + } + } +} + +// Ship POMs only (parity with the harvested repo); marker/plugin resolution works off POMs. +tasks.withType().configureEach { enabled = false } + tasks.withType { - compilerOptions { - apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - } + compilerOptions { + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + } }