Skip to content
Merged
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
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ appcompatVersion = "1.7.1"
bcprovJdk18on = "1.80"
colorpickerview = "2.3.0"
commonsCompress = "1.27.1"
tukaaniXz = "1.9"
commonsText = "1.11.0"
commonsTextVersion = "1.14.0"
constraintlayout = "2.1.4"
Expand Down Expand Up @@ -104,6 +105,7 @@ bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bc
bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprovJdk18on" }
colorpickerview = { module = "com.github.skydoves:colorpickerview", version.ref = "colorpickerview" }
commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commonsCompress" }
tukaani-xz = { module = "org.tukaani:xz", version.ref = "tukaaniXz" }
commons-text = { module = "org.apache.commons:commons-text", version.ref = "commonsText" }
commons-text-v1140 = { module = "org.apache.commons:commons-text", version.ref = "commonsTextVersion" }
composite-appintro = { module = "com.itsaky.androidide.build:appintro" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ enum class PluginPermission(val key: String, val description: String) {
SYSTEM_COMMANDS("system.commands", "Execute system commands"),
IDE_SETTINGS("ide.settings", "Modify IDE settings"),
PROJECT_STRUCTURE("project.structure", "Modify project structure"),
NATIVE_CODE("native.code", "Execute native machine code")
NATIVE_CODE("native.code", "Execute native machine code"),
IDE_ENVIRONMENT_WRITE("ide.environment.write", "Write to IDE-managed directories such as the Android SDK, NDK, and cache")
}

data class PluginInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package com.itsaky.androidide.plugins
import android.content.Context
// Note: EventBus and ILogger are referenced but not directly imported to avoid Android dependencies
import java.io.File
import java.io.InputStream

interface PluginContext {
val androidContext: Context
Expand Down Expand Up @@ -38,6 +39,31 @@ interface ResourceManager {
fun getPluginDirectory(): File
fun getPluginFile(path: String): File
fun getPluginResource(name: String): ByteArray?

/**
* Opens a plugin-bundled classpath resource as a stream. Prefer this over
* [getPluginResource] for payloads larger than a few megabytes since
* [getPluginResource] materializes the entire blob on the heap.
*
* Reads from `src/main/resources/`. For Android-style bundled binaries
* (files under `src/main/assets/`), use [openPluginAsset] instead.
*
* Callers own the returned stream and must close it.
*/
fun openPluginResource(name: String): InputStream?

/**
* Opens a file bundled in the plugin's `src/main/assets/` directory.
* Preferred for large binary payloads such as toolchains or models,
* since assets are the Android-native location and are not subject to
* classpath scanning.
*
* Callers own the returned stream and must close it.
*
* @param path Path relative to the plugin's assets root. Supports
* subdirectories (e.g. `"toolchains/ndk-cmake.tar.xz"`).
*/
fun openPluginAsset(path: String): InputStream?
}

interface PluginLogger {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.itsaky.androidide.plugins.services

import java.io.File
import java.io.InputStream

/**
* Supported archive formats for [IdeArchiveService.extract].
*
* - [XZ] and [GZIP] describe a single compressed stream. The stream content
* is written to [IdeArchiveService.extract]'s destination as a single file.
* - [TAR], [TAR_XZ], [TAR_GZ], and [ZIP] describe multi-entry archives.
* Entries are extracted into [IdeArchiveService.extract]'s destination,
* which must be a directory.
*/
enum class ArchiveFormat {
XZ,
GZIP,
TAR,
TAR_XZ,
TAR_GZ,
ZIP
}

/**
* Outcome of an extraction call. Uses a sealed hierarchy so callers
* handle both paths exhaustively.
*/
sealed class ExtractResult {
data class Success(
val bytesWritten: Long,
val filesExtracted: Int
) : ExtractResult()

data class Failure(val error: Throwable) : ExtractResult()
}

/**
* Service interface that extracts archives bundled inside plugin assets.
*
* The implementation handles compression libraries centrally so plugins do
* not each need to bundle `org.tukaani:xz` or `commons-compress`. Destination
* paths are validated against the plugin's write policy, so an extraction
* that would write outside an allowed directory fails rather than leaks data.
*
* Extraction is synchronous and CPU-bound for large archives. Plugins should
* invoke from a background dispatcher. The call cooperates with coroutine
* cancellation by checking [Thread.interrupted] between entries; on
* interruption [extract] returns [ExtractResult.Failure] carrying an
* [InterruptedException].
*/
interface IdeArchiveService {
/**
* Extracts [source] into [destination].
*
* For single-stream formats ([ArchiveFormat.XZ], [ArchiveFormat.GZIP]),
* [destination] is the output file path.
* For multi-entry formats, [destination] is the directory into which
* entries are written; it is created if it does not exist.
*
* [onProgress] is invoked periodically with the running byte count and,
* for multi-entry formats, the name of the entry currently being
* extracted.
*
* The caller owns [source] and is responsible for closing it.
*
* @return [ExtractResult.Success] with the byte and file counts on
* success, [ExtractResult.Failure] carrying the cause on any error
* (IO, malformed archive, path traversal, cancellation).
*/
fun extract(
source: InputStream,
format: ArchiveFormat,
destination: File,
onProgress: ((bytesProcessed: Long, currentEntry: String?) -> Unit)? = null
): ExtractResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.itsaky.androidide.plugins.services

import java.io.File
import java.io.IOException

/**
* Service interface that exposes IDE-managed directories to plugins.
*
* Reading these paths is unrestricted. Writing to them through
* [IdeFileService] or [IdeArchiveService] requires the plugin to declare the
* [com.itsaky.androidide.plugins.PluginPermission.IDE_ENVIRONMENT_WRITE]
* permission in its manifest.
*/
interface IdeEnvironmentService {
/**
* Root of IDE-managed data. Holds caches, templates, snippets, tooling,
* and per-plugin data.
*/
fun getIdeHomeDirectory(): File

/**
* Root of the Android SDK installation managed by the IDE. Platforms,
* build-tools, and the NDK live beneath this directory.
*/
fun getAndroidHomeDirectory(): File

/**
* Directory where NDK installations reside. Equivalent to
* `$ANDROID_HOME/ndk`.
*/
fun getNdkDirectory(): File

/**
* Scratch directory for short-lived files such as staging archives
* during extraction. Contents may be evicted at any time between plugin
* activations; do not rely on persistence.
*/
fun getTmpDirectory(): File

/**
* Per-plugin persistent data directory. Scoped to the calling plugin and
* created on first access. Survives IDE restarts.
*
* @throws IOException if the directory does not exist and cannot be created.
*/
@Throws(IOException::class)
fun getPluginDataDirectory(): File
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package com.itsaky.androidide.plugins.services
import android.app.Activity
import com.itsaky.androidide.plugins.extensions.IProject
import java.io.File
import java.io.InputStream

/**
* Service interface that provides access to Code On the Go project information.
Expand Down Expand Up @@ -228,6 +229,37 @@ interface IdeFileService {
* @return true if the replacement was successful, false otherwise
*/
fun replaceInFile(file: File, oldText: String, newText: String): Boolean

/**
* Writes binary content to a file, replacing any existing content.
* Use this instead of [writeFile] for non-text data: UTF-8 transcoding in
* [writeFile] corrupts arbitrary bytes.
* @param file The file to write to
* @param data The bytes to write
* @return true if the write operation was successful, false otherwise
*/
fun writeBinary(file: File, data: ByteArray): Boolean

/**
* Writes content from an input stream to a file, replacing any existing
* content. Preferred for large payloads (archives, toolchain assets) since
* no intermediate buffer of the full payload is held in memory.
*
* The caller owns [input] and is responsible for closing it.
* @param file The file to write to
* @param input The stream to read from
* @return The number of bytes written, or -1 if the operation failed
*/
fun writeStream(file: File, input: InputStream): Long

/**
* Deletes a file or directory. Directories are removed recursively.
* Required for plugins that need to clean up installed assets in
* [com.itsaky.androidide.plugins.IPlugin.deactivate].
* @param file The file or directory to delete
* @return true if the deletion was successful, false otherwise
*/
fun delete(file: File): Boolean
}

/**
Expand Down
2 changes: 2 additions & 0 deletions plugin-manager/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,6 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.gson.v2101)
implementation(libs.brotli4j)
implementation(libs.commons.compress)
implementation(libs.tukaani.xz)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
package com.itsaky.androidide.plugins.manager.context

import android.content.Context
import android.content.res.AssetManager
import com.itsaky.androidide.plugins.*
import com.itsaky.androidide.plugins.manager.security.PluginSecurityManager
import java.io.File
import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap

class PluginContextImpl(
Expand Down Expand Up @@ -42,31 +44,32 @@ class ServiceRegistryImpl : ServiceRegistry {
class ResourceManagerImpl(
private val pluginId: String,
private val pluginsDir: File,
private val classLoader: ClassLoader
private val classLoader: ClassLoader,
private val assetManager: AssetManager? = null
) : ResourceManager {

private val pluginDirectory = File(pluginsDir, pluginId)
private val securityManager = PluginSecurityManager()

init {
if (!pluginDirectory.exists()) {
pluginDirectory.mkdirs()
}
}

override fun getPluginDirectory(): File = pluginDirectory

override fun getPluginFile(path: String): File {
val file = File(pluginDirectory, path)

// Security check: ensure file is within plugin directory
if (!file.canonicalPath.startsWith(pluginDirectory.canonicalPath)) {
throw SecurityException("Access denied: Path traversal detected")
}

return file
}

override fun getPluginResource(name: String): ByteArray? {
return try {
classLoader.getResourceAsStream(name)?.use {
Expand All @@ -77,6 +80,22 @@ class ResourceManagerImpl(
}
}

override fun openPluginResource(name: String): InputStream? {
return try {
classLoader.getResourceAsStream(name)
} catch (e: Exception) {
null
}
}

override fun openPluginAsset(path: String): InputStream? {
val assets = assetManager ?: return null
return try {
assets.open(path)
} catch (e: Exception) {
null
}
}
}

class PluginLoggerImpl(
Expand Down
Loading
Loading