Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e07079e
ADFA-4911: Add design spec for injecting plugin coordinates into loca…
hal-eisen-adfa Jul 29, 2026
f7cee73
ADFA-4911: Amend spec for verified installer wipe/concurrency + modul…
hal-eisen-adfa Jul 29, 2026
9bf3f1a
ADFA-4911: Add implementation plan (7 tasks, build->ship->merge->verify)
hal-eisen-adfa Jul 29, 2026
10ed706
ADFA-4911: Publish plugin-builder (impl POM + Gradle plugin marker) t…
hal-eisen-adfa Jul 29, 2026
abeb826
ADFA-4911: Assemble fat plugin-api jar (plugin-api + common + eventbu…
hal-eisen-adfa Jul 29, 2026
9965dc7
ADFA-4911: Assemble plugin-maven-repo.zip (plugin-api coordinate + bu…
hal-eisen-adfa Jul 29, 2026
015f3a2
ADFA-4911: Ship plugin-maven-repo.zip as a bundled (.br) and split asset
hal-eisen-adfa Jul 29, 2026
c5e5aa2
ADFA-4911: Merge plugin coordinate overlay into localMvnRepository du…
hal-eisen-adfa Jul 29, 2026
bc6a9a5
ADFA-4911: Document the plugin-api:1.0.0 coordinate and coordinate-ba…
hal-eisen-adfa Jul 29, 2026
b6d2c05
ADFA-4911: Builder targets on-device AGP (8.11.0) as compileOnly, not…
hal-eisen-adfa Jul 29, 2026
de8a20d
ADFA-4911: Emit Kotlin metadata the on-device compiler (1.9.22) can r…
hal-eisen-adfa Jul 30, 2026
9cb2b17
ADFA-4911: Drop unused mockkObject from ExtractZipToDirMergeTest
hal-eisen-adfa Jul 30, 2026
5ac1022
ADFA-4911: Document on-device AGP/Kotlin versions + root plugins requ…
hal-eisen-adfa Jul 30, 2026
f896315
ADFA-4911: Create writePluginApiPom output dir before writing
hal-eisen-adfa Jul 30, 2026
a27262b
ADFA-4911: Align plan + design spec with shipped dependency model
hal-eisen-adfa Jul 30, 2026
1c379e7
Merge branch 'stage' into ADFA-4911-inject-plugin-jars-localmvn
hal-eisen-adfa Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ tests/test-home
# Plugin build artifacts
plugin-api.jar
plugin-artifacts.zip
plugin-maven-repo.zip

# Other IDEs
.cursor/
Expand Down
101 changes: 101 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,104 @@ tasks.register<Zip>("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<Jar>("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(
"""<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.itsaky.androidide</groupId>
<artifactId>plugin-api</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
</project>
""",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

// Assembles the shippable Maven layout: the fat plugin-api coordinate + the
// builder impl/POM/marker published by the plugin-builder included build.
tasks.register<Zip>("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-<arch>.zip) consumed by
// SplitAssetsInstaller on debug builds. Entry names must match the installer
// contract in AssetsInstallationHelper.expectedEntries.
Expand All @@ -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)
Expand All @@ -484,6 +583,7 @@ fun createAssetsZip(arch: String) {

tasks.register("assembleV8Assets") {
dependsOn("createPluginArtifactsZip")
dependsOn("createPluginMavenRepoZip")
if (!isCiCd) {
dependsOn("assetsDownloadDebug")
}
Expand All @@ -494,6 +594,7 @@ tasks.register("assembleV8Assets") {

tasks.register("assembleV7Assets") {
dependsOn("createPluginArtifactsZip")
dependsOn("createPluginMavenRepoZip")
if (!isCiCd) {
dependsOn("assetsDownloadDebug")
}
Expand Down
193 changes: 112 additions & 81 deletions app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand All @@ -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 ->
Expand All @@ -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")
}
Expand All @@ -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
Expand Down
Loading
Loading