Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() {
append("Version: ${plugin.metadata.version}\n")
append("Author: ${plugin.metadata.author}\n")
append("Description: ${plugin.metadata.description}\n")
append("Min IDE Version: ${plugin.metadata.minIdeVersion}\n")
append("Min Plugin API Version: ${plugin.metadata.minPluginApiVersion}\n")
append("Permissions: ${plugin.metadata.permissions.joinToString(", ")}\n")
}

Expand Down
14 changes: 14 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,23 @@ plugins {
alias(libs.plugins.google.protobuf) apply false
alias(libs.plugins.spotless)
alias(libs.plugins.sonarqube)
alias(libs.plugins.binary.compatibility.validator)
id("jacoco")
}

apiValidation {
val pluginApiProjects = setOf("plugin-api")
rootProject.subprojects.forEach { subproject ->
if (subproject.name !in pluginApiProjects) {
ignoredProjects.add(subproject.name)
}
}

nonPublicMarkers.add("com.itsaky.androidide.plugins.InternalPluginApi")

ignoredClasses.add("com.itsaky.androidide.plugins.api.BuildConfig")
}

jacoco {
toolVersion = "0.8.11"
}
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ libsu-core = "6.0.0"
## put it in app/src/main/jniLibs
brotli4j = "1.18.0"
spotless = "7.2.1"
binary-compatibility-validator = "0.18.1"
fragmentKtx = "1.8.8"

protobuf = "4.32.1"
Expand Down Expand Up @@ -332,3 +333,4 @@ rikka-materialthemebuilder = { id = "dev.rikka.tools.materialthemebuilder", vers
rikka-refine = { id = "dev.rikka.tools.refine", version.ref = "rikka-refine" }

sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" }
binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" }
1,309 changes: 1,309 additions & 0 deletions plugin-api/api/plugin-api.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ data class PluginMetadata(
val version: String,
val description: String,
val author: String,
val minIdeVersion: String,
val minPluginApiVersion: String,
val permissions: List<String> = emptyList(),
val dependencies: List<String> = emptyList(),
val iconDayPath: String? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.itsaky.androidide.plugins

@RequiresOptIn(
level = RequiresOptIn.Level.ERROR,
message = "This declaration is internal to the IDE and is not part of the plugin API contract. " +
"It may change or be removed at any time without a major version bump."
)
@Retention(AnnotationRetention.BINARY)
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
AnnotationTarget.CONSTRUCTOR,
AnnotationTarget.TYPEALIAS,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.PROPERTY_SETTER,
)
annotation class InternalPluginApi
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.itsaky.androidide.plugins

/**
* Plugin API contract version (SemVer). Bump major for binary-incompatible changes
* flagged by `:plugin-api:apiCheck`, minor for additive changes; then run
* `:plugin-api:apiDump` to refresh `plugin-api/api/plugin-api.api`.
*/
object PluginApiVersion {

const val CURRENT: String = "1.0.0"
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import com.itsaky.androidide.plugins.manager.loaders.PluginManifest
import com.itsaky.androidide.plugins.manager.loaders.PluginLoader
import com.itsaky.androidide.plugins.manager.loaders.toPluginMetadata
import com.itsaky.androidide.plugins.manager.loaders.PluginResourceContext
import com.itsaky.androidide.plugins.manager.security.PluginApiIncompatibleException
import com.itsaky.androidide.plugins.manager.security.PluginApiVersionChecker
import com.itsaky.androidide.plugins.manager.security.PluginSecurityManager
import com.itsaky.androidide.plugins.manager.context.PluginContextImpl
import com.itsaky.androidide.plugins.manager.context.PluginLoggerImpl
Expand Down Expand Up @@ -394,6 +396,12 @@ class PluginManager private constructor(
return Result.failure(SecurityException("plugin failed security validation: ${manifest.id}"))
}

PluginApiVersionChecker.requireCompatible(
pluginId = manifest.id,
required = manifest.minPluginApiVersion ?: "1.0.0",
current = PluginApiVersion.CURRENT,
)

// Validate sidebar slots BEFORE loading plugin code
if (manifest.sidebarItems > 0) {
val available = SidebarSlotManager.getAvailableSlotsForPlugins()
Expand Down Expand Up @@ -514,6 +522,12 @@ class PluginManager private constructor(

activateLoadedPlugin(loadedPlugin)
Result.success(plugin)
} catch (e: PluginApiIncompatibleException) {
reservedSlotsPluginId?.let { pluginId ->
SidebarSlotManager.releasePluginSlots(pluginId)
}
logger.error("Plugin API incompatibility for ${file.name}: ${e.message}")
Result.failure(e)
} catch (e: Exception) {
reservedSlotsPluginId?.let { pluginId ->
SidebarSlotManager.releasePluginSlots(pluginId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,7 @@ class PluginLoader(
val pluginDescription = metaData.getString("plugin.description") ?: ""
val pluginAuthor = metaData.getString("plugin.author") ?: ""
val pluginMainClass = metaData.getString("plugin.main_class") ?: return null
val pluginMinIdeVersion = metaData.getString("plugin.min_ide_version") ?: "1.0.0"
val pluginMaxIdeVersion = metaData.getString("plugin.max_ide_version")
val pluginMinPluginApiVersion = metaData.getString("plugin.min_plugin_api_version") ?: "1.0.0"

// Parse permissions
val permissions = metaData.getString("plugin.permissions")?.split(",")?.map { it.trim() } ?: emptyList()
Expand All @@ -262,8 +261,7 @@ class PluginLoader(
description = pluginDescription,
author = pluginAuthor,
mainClass = pluginMainClass,
minIdeVersion = pluginMinIdeVersion,
maxIdeVersion = pluginMaxIdeVersion,
minPluginApiVersion = pluginMinPluginApiVersion,
permissions = permissions,
dependencies = dependencies,
extensions = emptyList(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,9 @@ data class PluginManifest(
@SerializedName("main_class")
val mainClass: String,

@SerializedName("min_ide_version")
val minIdeVersion: String,

@SerializedName("max_ide_version")
val maxIdeVersion: String? = null,

@SerializedName("min_plugin_api_version")
val minPluginApiVersion: String? = null,

@SerializedName("permissions")
val permissions: List<String> = emptyList(),

Expand Down Expand Up @@ -95,7 +92,7 @@ fun PluginManifest.toPluginMetadata() = PluginMetadata(
version = version,
description = description,
author = author,
minIdeVersion = minIdeVersion,
minPluginApiVersion = minPluginApiVersion ?: "1.0.0",
dependencies = dependencies,
permissions = permissions
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.itsaky.androidide.plugins.manager.security

class PluginApiIncompatibleException(
val pluginId: String,
val requiredVersion: String,
val availableVersion: String,
val reason: Reason,
) : Exception(buildMessage(pluginId, requiredVersion, availableVersion, reason)) {

enum class Reason {
MAJOR_MISMATCH,
REQUIRES_NEWER,
MALFORMED_VERSION,
}

private companion object {
fun buildMessage(
pluginId: String,
requiredVersion: String,
availableVersion: String,
reason: Reason,
): String = when (reason) {
Reason.MAJOR_MISMATCH ->
"Plugin '$pluginId' targets plugin API $requiredVersion, " +
"but this IDE provides $availableVersion (incompatible major version)."
Reason.REQUIRES_NEWER ->
"Plugin '$pluginId' requires plugin API $requiredVersion, " +
"but this IDE provides $availableVersion. Update the IDE to use this plugin."
Reason.MALFORMED_VERSION ->
"Plugin '$pluginId' declares an invalid plugin API version: '$requiredVersion'."
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.itsaky.androidide.plugins.manager.security

internal object PluginApiVersionChecker {

private val SEMVER = Regex("^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?$")

fun isCompatible(required: String, current: String): Boolean {
val r = parse(required) ?: return false
val c = parse(current) ?: error("Invalid current plugin API version: '$current'")
return r.major == c.major &&
(r.minor < c.minor || (r.minor == c.minor && r.patch <= c.patch))
}

fun requireCompatible(pluginId: String, required: String, current: String) {
val r = parse(required) ?: throw PluginApiIncompatibleException(
pluginId = pluginId,
requiredVersion = required,
availableVersion = current,
reason = PluginApiIncompatibleException.Reason.MALFORMED_VERSION,
)
val c = parse(current) ?: error("Invalid current plugin API version: '$current'")
if (r.major != c.major) {
throw PluginApiIncompatibleException(
pluginId = pluginId,
requiredVersion = required,
availableVersion = current,
reason = PluginApiIncompatibleException.Reason.MAJOR_MISMATCH,
)
}
if (r.minor > c.minor || (r.minor == c.minor && r.patch > c.patch)) {
throw PluginApiIncompatibleException(
pluginId = pluginId,
requiredVersion = required,
availableVersion = current,
reason = PluginApiIncompatibleException.Reason.REQUIRES_NEWER,
)
}
}

private data class Version(val major: Int, val minor: Int, val patch: Int)

private fun parse(raw: String): Version? {
val match = SEMVER.matchEntire(raw.trim()) ?: return null
return Version(
major = match.groupValues[1].toInt(),
minor = match.groupValues[2].ifBlank { "0" }.toInt(),
patch = match.groupValues[3].ifBlank { "0" }.toInt(),
Comment on lines +45 to +47
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toInt might throw in case of invalid input. Please add safeguards here (and test cases).

)
Comment on lines +42 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionChecker.kt

Repository: appdevforall/CodeOnTheGo

Length of output: 2453


🏁 Script executed:

find . -type f -name "*PluginApiVersionChecker*Test*" -o -name "*Test*PluginApiVersionChecker*"

Repository: appdevforall/CodeOnTheGo

Length of output: 179


🏁 Script executed:

rg -l "PluginApiVersionChecker" --type kotlin

Repository: appdevforall/CodeOnTheGo

Length of output: 373


🏁 Script executed:

cat -n ./plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionCheckerTest.kt

Repository: appdevforall/CodeOnTheGo

Length of output: 5711


Catch NumberFormatException in parse() to properly handle oversized numeric segments.

Input like 999999999999.0.0 matches the SEMVER regex but causes toInt() to throw NumberFormatException on overflow, escaping as an uncaught exception instead of returning null. This breaks the intended contract where requireCompatible() expects null to signal MALFORMED_VERSION. Wrap the Version construction in a try-catch block to return null on parse failure, and add a regression test for numeric overflow.

🔧 Targeted fix
     private fun parse(raw: String): Version? {
         val match = SEMVER.matchEntire(raw.trim()) ?: return null
-        return Version(
-            major = match.groupValues[1].toInt(),
-            minor = match.groupValues[2].ifBlank { "0" }.toInt(),
-            patch = match.groupValues[3].ifBlank { "0" }.toInt(),
-        )
+        return try {
+            Version(
+                major = match.groupValues[1].toInt(),
+                minor = match.groupValues[2].ifBlank { "0" }.toInt(),
+                patch = match.groupValues[3].ifBlank { "0" }.toInt(),
+            )
+        } catch (_: NumberFormatException) {
+            null
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionChecker.kt`
around lines 42 - 48, The parse(raw: String) function can throw
NumberFormatException when converting oversized numeric segments with toInt();
wrap the Version(...) construction inside a try-catch that catches
NumberFormatException (and any other NumberFormat-related exceptions you
consider relevant) and return null on failure so malformed/overflowing inputs
map to null as expected by requireCompatible(); update/add a regression test to
feed an oversized segment like "999999999999.0.0" and assert parse returns null
(or that requireCompatible yields MALFORMED_VERSION).

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ class PluginSecurityManager {
return manifest.id.isNotBlank() &&
manifest.name.isNotBlank() &&
manifest.version.isNotBlank() &&
manifest.mainClass.isNotBlank() &&
manifest.minIdeVersion.isNotBlank()
manifest.mainClass.isNotBlank()
}

private fun validatePermissions(permissions: List<String>): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.itsaky.androidide.plugins.manager.security

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test

class PluginApiVersionCheckerTest {

@Test
fun `same version is compatible`() {
assertTrue(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "1.0.0"))
}

@Test
fun `IDE with newer minor accepts plugin built against older minor`() {
assertTrue(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "1.5.0"))
}

@Test
fun `IDE with newer patch accepts plugin built against older patch`() {
assertTrue(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "1.0.5"))
}

@Test
fun `plugin requiring newer minor is incompatible`() {
assertFalse(PluginApiVersionChecker.isCompatible(required = "1.5.0", current = "1.0.0"))
}

@Test
fun `plugin requiring newer patch within same minor is incompatible`() {
assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.5", current = "1.0.0"))
}

@Test
fun `plugin built for older major is incompatible`() {
assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "2.0.0"))
}

@Test
fun `plugin built for newer major is incompatible`() {
assertFalse(PluginApiVersionChecker.isCompatible(required = "2.0.0", current = "1.0.0"))
}

@Test
fun `major-only shorthand parses as major dot zero dot zero`() {
assertTrue(PluginApiVersionChecker.isCompatible(required = "1", current = "1.0.0"))
assertTrue(PluginApiVersionChecker.isCompatible(required = "1", current = "1.5.3"))
assertFalse(PluginApiVersionChecker.isCompatible(required = "2", current = "1.0.0"))
}

@Test
fun `major-minor shorthand parses with patch zero`() {
assertTrue(PluginApiVersionChecker.isCompatible(required = "1.2", current = "1.2.0"))
assertTrue(PluginApiVersionChecker.isCompatible(required = "1.2", current = "1.2.5"))
assertFalse(PluginApiVersionChecker.isCompatible(required = "1.3", current = "1.2.0"))
}

@Test
fun `malformed version is incompatible`() {
assertFalse(PluginApiVersionChecker.isCompatible(required = "v1.0.0", current = "1.0.0"))
assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.0-snapshot", current = "1.0.0"))
assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.0.0", current = "1.0.0"))
assertFalse(PluginApiVersionChecker.isCompatible(required = "", current = "1.0.0"))
assertFalse(PluginApiVersionChecker.isCompatible(required = "abc", current = "1.0.0"))
}

@Test
fun `whitespace is trimmed before parsing`() {
assertTrue(PluginApiVersionChecker.isCompatible(required = " 1.0.0 ", current = "1.0.0"))
}

@Test
fun `requireCompatible does not throw on compatible versions`() {
PluginApiVersionChecker.requireCompatible("p", required = "1.0.0", current = "1.5.0")
}

@Test
fun `requireCompatible throws MAJOR_MISMATCH for cross-major rejection`() {
val ex = assertThrows(PluginApiIncompatibleException::class.java) {
PluginApiVersionChecker.requireCompatible("plugin.x", required = "2.0.0", current = "1.0.0")
}
assertEquals("plugin.x", ex.pluginId)
assertEquals("2.0.0", ex.requiredVersion)
assertEquals("1.0.0", ex.availableVersion)
assertEquals(PluginApiIncompatibleException.Reason.MAJOR_MISMATCH, ex.reason)
}

@Test
fun `requireCompatible throws REQUIRES_NEWER when plugin asks newer minor`() {
val ex = assertThrows(PluginApiIncompatibleException::class.java) {
PluginApiVersionChecker.requireCompatible("plugin.y", required = "1.5.0", current = "1.0.0")
}
assertEquals(PluginApiIncompatibleException.Reason.REQUIRES_NEWER, ex.reason)
}

@Test
fun `requireCompatible throws MALFORMED_VERSION for unparseable input`() {
val ex = assertThrows(PluginApiIncompatibleException::class.java) {
PluginApiVersionChecker.requireCompatible("plugin.z", required = "not-a-version", current = "1.0.0")
}
assertEquals(PluginApiIncompatibleException.Reason.MALFORMED_VERSION, ex.reason)
assertEquals("not-a-version", ex.requiredVersion)
}

@Test
fun `requireCompatible throws REQUIRES_NEWER for newer patch within same minor`() {
val ex = assertThrows(PluginApiIncompatibleException::class.java) {
PluginApiVersionChecker.requireCompatible("p", required = "1.0.5", current = "1.0.0")
}
assertEquals(PluginApiIncompatibleException.Reason.REQUIRES_NEWER, ex.reason)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ fun pluginAndroidManifest(data: PluginTemplateData): String {
android:value="${data.author}" />
<meta-data
android:name="plugin.min_ide_version"
android:name="plugin.min_plugin_api_version"
android:value="${data.minIdeVersion}" />
<!--
Expand Down
Loading