Hey! Welcome to the Android Code Studio Plugin API docs. This guide covers everything you need to know to build plugins that interact with the IDE. Whether you want to mess with the editor, launch processes, show custom UI, or even register your own language server, this is where you'll find it all.
- Getting Started
- PluginApi - The Entry Point
- PluginContext - Your Sandboxed Context
- IEditorApi - Working with the Editor
- IEnvironmentApi - Paths and Environment
- ILspApi - Language Server Protocol
- IProcessApi - Launching Processes
- ITemplateApi - Project and File Templates
- IUiApi - Custom Compose UI Overlays
- Security Model
- Common Patterns and Examples
- Things You Should Never Do
Plugins interact with the IDE exclusively through the PluginApi object. You never get a direct reference to any internal IDE class. That's intentional, the API surface is designed to be stable, safe, and versioned.
For Groovy:
// Plugins block plugins { ... // For packaging and producing a .acp plugin file id 'io.github.nullij.acside-gradle-plugin' version '0.2.0' } dependencies { ... implementation 'com.github.AndroidCSIDE:acside-plugins-api:0.3.1' }
If you're using the Gradle Kotlin DSL:
// Plugins block plugins { ... id("io.github.nullij.acside-gradle-plugin").version("0.2.0") } dependencies { ... implementation("com.github.AndroidCSIDE:acside-plugins-api:0.3.1") }
Ensure that JitPack is added to your Gradle settings file.
For Groovy:
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
// ...
// for acside-plugin-api
maven { url 'https://jitpack.io' }
}
}For Kotlin DSL:
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
// ...
maven { url = uri("https://jitpack.io") }
}
}The IDE wires everything up before your plugin action fires, so you don't need to do any setup. Just access what you need through PluginApi.
import com.nullij.androidcodestudio.plugins.api.PluginApi // That's it. Just use it. val editor = PluginApi.editor val env = PluginApi.environment
Important: All accessors throw
IllegalStateExceptionif the IDE hasn't initialized yet. In practice this won't happen during normal plugin execution, but if you're doing something unusual (like background threads that outlive your action), keep it in mind.
PluginApi is a Kotlin singleton (object) that gives you access to all the sub-APIs.
object PluginApi { val editor: IEditorApi val environment: IEnvironmentApi val lsp: ILspApi? // null outside EditorActivity val templates: ITemplateApi val process: IProcessApi val ui: IUiApi? // null outside EditorActivity }
lsp and ui can be null. Both of them only exist when the user is inside EditorActivity (i.e. has a file open in the editor). If your plugin action can be triggered from the home screen or a project list, always null-check these two before using them.
// Safe pattern PluginApi.lsp?.let { lsp -> // We're inside the editor, lsp is available lsp.startServer("kotlin") } // Also fine val ui = PluginApi.ui ?: return // bail early if not in editor ui.showOverlay { handle -> MyDialog(onDismiss = { handle.dismiss() }) }
The rest (editor, environment, templates, process) are always available and never null once the IDE boots.
Your plugin entry point receives a PluginContext instead of a raw Android Context. This is on purpose. The IDE does not want plugins holding references to Activity objects, launching other activities, or binding to arbitrary services.
// Get the app package name val pkg = context.packageName // Post work to the main (UI) thread context.runOnUiThread { // Update UI state, show a Toast, etc. } // Get a safe applicationContext (not the real one) val appCtx = context.applicationContext
getSystemService() is whitelisted. Only these work:
| Service | Constant |
|---|---|
| Clipboard | Context.CLIPBOARD_SERVICE |
| Connectivity | Context.CONNECTIVITY_SERVICE |
| Input Method | Context.INPUT_METHOD_SERVICE |
| Vibrator | Context.VIBRATOR_SERVICE |
| Notifications | Context.NOTIFICATION_SERVICE |
Anything else throws SecurityException. No sneaking around it by calling applicationContext.getSystemService() either, that's sandboxed too.
The PluginSandboxContext (which is what applicationContext returns) blocks a bunch of things:
startActivity()- plugins can't launch activitiesstartService()/bindService()- no binding to servicessendBroadcast()- no sending broadcastsregisterReceiver()- no registering broadcast receiversgetContentResolver()- no raw content resolver access
If you need to interact with content or show new screens, use the dedicated IDE APIs instead.
Access via PluginApi.editor. All methods are thread-safe, the implementation handles marshaling internally.
Always do this first if there's any chance the editor might not be visible.
if (!PluginApi.editor.isAvailable()) { // No editor open, bail out or show a message return }
val editor = PluginApi.editor // Get the full file content val content = editor.getText() // Replace the entire file content (goes into undo history) editor.setText("fun main() {\n println(\"Hello\")\n}\n")
Cursor positions are 0-indexed internally. The Display variants give you 1-indexed values for showing in a status bar.
// 0-indexed val line = editor.getCurrentLine() val col = editor.getCurrentColumn() // 1-indexed (for UI display) val lineDisplay = editor.getCurrentLineDisplay() val colDisplay = editor.getCurrentColumnDisplay() // Move the cursor (0-indexed, clamped silently if out of bounds) editor.setCursor(line = 5, column = 0)
if (editor.hasSelection()) { val selected = editor.getSelectedText() // Do something with selected text } // Select everything editor.selectAll()
editor.copy() // copies selection, no-op if nothing selected editor.cut() // cuts selection, no-op if nothing selected editor.paste() // pastes at cursor
if (editor.canUndo()) editor.undo() if (editor.canRedo()) editor.redo()
// Insert at current cursor - preferred way to inject code snippets editor.insertText("// TODO: implement this\n") // Delete currently selected text (no-op if nothing selected) editor.deleteSelection()
// Triggers the language server formatter asynchronously
editor.formatDocument()Access via PluginApi.environment. Gives you the file paths the IDE uses internally. Everything is read-only from the plugin side.
val env = PluginApi.environment // The currently open project (null if no project open or outside EditorActivity) val project = env.openProjectDir // The main projects directory on external storage val projects = env.projectsDir // ~/AndroidCSProjects // Internal storage projects root val acsRoot = env.acsRootProjects // $HOME/AndroidCSProjects (internal)
val sdk = env.androidSdkDir // $HOME/Android/Sdk val flutter = env.flutterDir // $HOME/flutter
val files = env.filesDir // Context.filesDir val home = env.homeDir // $filesDir/home val local = env.localDir // $filesDir/localenv val tmp = env.tmpDir // $localDir/tmp
These are string paths as they appear inside the proot environment, not host filesystem paths.
val sdkInRootfs = env.rootfsAndroidSdkPath // Android SDK path inside rootfs val javaHome = env.rootfsJavaHome // JAVA_HOME inside rootfs
// Get the full env map the IDE uses for subprocesses val envMap = env.getEnvironment() // Merge in your own overrides val envMap = env.getEnvironment( additionalEnv = mapOf("MY_VAR" to "my_value") )
if (!env.isInitialized()) { // IDE environment isn't ready yet return }
Access via PluginApi.lsp. This is null outside of EditorActivity. Always null-check it.
The LSP API lets you query and control language servers, register your own, and send document lifecycle events.
val lsp = PluginApi.lsp ?: return // Is there a server registered for this language? lsp.hasServer("kotlin") // true/false // Is it actually running right now? lsp.isServerRunning("kotlin") // true/false // What servers exist / are running? val available = lsp.getAvailableServers() // Set<String> val running = lsp.getRunningServers() // Set<String>
val started = lsp.startServer("kotlin") // returns true if started or already running lsp.stopServer("kotlin") lsp.stopAllServers()
These tell the registry what's happening with files. Usually the IDE calls these automatically, but plugins can trigger them too.
val file = File("/path/to/MyFile.kt") lsp.openDocument(file) // IDE will start the right server if needed lsp.closeDocument(file) lsp.documentChanged(file, content = "...", version = 2) // version must be increasing
val langId = lsp.detectLanguage(file) // from a File object val langId2 = lsp.detectLanguage("Main.kt") // from a filename string // Returns null if unrecognized
This is the main extension point for plugins that want to add support for a new language. Implement PluginLanguageServerSpec and register it.
class MyLspServer : PluginLanguageServerSpec { override val languageId = "mylang" override fun start(): Boolean { // Start your LSP process here return true } override fun stop() { // Stop it } override fun isRunning(): Boolean = /* ... */ override fun getClient(): LanguageServerClient { // Return a LanguageServerClient wrapper around your actual client return myClient } override fun openDocument(file: File): Boolean { // Notify your server return true } override fun closeDocument(file: File) { /* ... */ } override fun documentChanged(file: File, content: String, version: Int) { /* ... */ } } // Register it lsp.registerServer("mylang", MyLspServer()) // Associate file extensions lsp.registerExtension("ml", "mylang") lsp.registerExtension("mli", "mylang") // Check current registrations val extensions = lsp.getRegisteredExtensions() // Map<String, String> // Clean up lsp.unregisterServer("mylang") lsp.unregisterExtension("ml")
Access via PluginApi.process. Used to launch executables inside the IDE's proot / acsenv environment. The IDE handles the environment setup, you just describe what to run.
val process = PluginApi.process .builder() .command("/bin/bash-language-server", "start") .attachStorage() .withEnv(mapOf("HOME" to "/root")) .launch()
val process = PluginApi.process .builder() // The executable + arguments, as they appear inside the rootfs .command("/usr/local/bin/pylsp") // Bind a host directory into the proot environment // If mountAt is omitted, mounts at the same path .attachDir(hostDir = File("/data/user/0/my.app/lsp-bins"), mountAt = "/root/lsp") // Bind the IDE's internal storage (needed for SDK, home dir, etc.) .attachStorage() // Merge extra environment variables .withEnv(mapOf( "LOG_LEVEL" to "debug", "SERVER_PORT" to "2087" )) // Actually launch it - returns a standard java.lang.Process .launch()
launch() returns a standard java.lang.Process, so you work with it the same way you would with any Java process:
val proc = PluginApi.process.builder() .command("/bin/my-server", "--stdio") .attachStorage() .launch() // Write to stdin proc.outputStream.bufferedWriter().use { writer -> writer.write(jsonRpcMessage) } // Read from stdout val reader = proc.inputStream.bufferedReader() // Wait for it to finish val exitCode = proc.waitFor() // Or kill it proc.destroy()
Access via PluginApi.templates. Lets you add new entries to the New Project / New File wizard.
val handle = PluginApi.templates.registerTemplate( TemplateSpec( displayName = "My Custom Activity", templateType = "ACTIVITY", onCreate = { context, options, rawOptions -> // options is typed TemplateOptionsData // rawOptions is the raw object for extra fields val structure = PluginApi.templates.createStandardStructure( projectDir = options.saveLocation, packageId = options.packageId ) // Write your template files PluginApi.templates.createFile( dir = structure.layoutDir, fileName = "activity_my_custom.xml", content = myLayoutXml ) } ) ) // Later, when your plugin is being cleaned up PluginApi.templates.unregisterTemplate(handle)
Common values for templateType:
"ACTIVITY"- a new Activity"FRAGMENT"- a new Fragment"PROJECT"- a full project
When your onCreate lambda fires, you get a TemplateOptionsData with all the user's choices:
data class TemplateOptionsData( val projectName: String, // What the user named the project val packageId: String, // e.g. "com.example.myapp" val minSdk: Int, // Minimum SDK version chosen val useKts: Boolean, // Whether to use Kotlin Script build files val saveLocation: File, // Where to create the project val languageType: String, // "KOTLIN" or "JAVA" val customFields: Map<String, String> = emptyMap(), // Template-specific wizard values )
val templates = PluginApi.templates // Create a chain of directories, returns the leaf dir val dir = templates.createDirectories( baseDir = someDir, "src", "main", "kotlin", "com", "example" ) // Write a file val file = templates.createFile( dir = dir, fileName = "MainActivity.kt", content = """ package com.example class MainActivity : AppCompatActivity() """.trimIndent() ) // Build the whole standard Android project structure at once val structure = templates.createStandardStructure( projectDir = options.saveLocation, packageId = options.packageId ) // structure.projectDir, .mainSrcDir, .javaDir, .resDir, .layoutDir, // .valuesDir, .drawableDir, .manifestFile, .packageId, .packagePath
data class ProjectStructure( val projectDir: File, val mainSrcDir: File, // src/main val javaDir: File, // src/main/java (or kotlin) val resDir: File, // src/main/res val layoutDir: File, // src/main/res/layout val valuesDir: File, // src/main/res/values val drawableDir: File, // src/main/res/drawable val manifestFile: File, // src/main/AndroidManifest.xml val packageId: String, val packagePath: String // e.g. "com/example/myapp" )
If your plugin is invoked via templates.json (dispatch-based), the options come in as raw Any. Use extractOptions to get a typed object:
onCreate = { context, _, rawOptions -> val options = PluginApi.templates.extractOptions(rawOptions) // Now options is a TemplateOptionsData }
Access via PluginApi.ui. This is null outside of EditorActivity. Always null-check.
Lets your plugin show arbitrary Jetpack Compose UI on top of the editor. You don't own the ComposeView or Recomposer, you just contribute a @Composable lambda that runs inside the existing composition.
val ui = PluginApi.ui ?: return val handle = ui.showOverlay { overlayHandle -> // This is a @Composable lambda MyPluginDialog( onConfirm = { /* do stuff */ overlayHandle.dismiss() }, onCancel = { overlayHandle.dismiss() } ) }
// If you held on to the handle handle.dismiss() // Or dismiss everything at once ui.dismissAll()
interface OverlayHandle { fun dismiss() // Remove from composition. Safe to call multiple times. val isShowing: Boolean // True while still visible }
showOverlay() must be called from the main thread. If you're on a background thread, use PluginContext.runOnUiThread:
context.runOnUiThread {
PluginApi.ui?.showOverlay { handle ->
MyDialog(onDismiss = { handle.dismiss() })
}
}The plugin system has multiple layers of protection. Here's a quick rundown so you understand why certain things don't work.
Plugins never get a raw Activity or Context. The PluginContext and PluginSandboxContext wrappers block anything that could let a plugin escape its sandbox (launching activities, binding services, sending broadcasts, accessing content providers directly).
Some methods on the public interfaces are marked @InternalPluginApi. These are compile-time-blocked for plugin code. If you try to call one, your plugin won't compile. They're there for the IDE's internal bridge layer.
// This will fail to compile in plugin code PluginApi.templates.callListenerMethod(...) // compile error // This too PluginApi.wire(...) // not even in your API surface
Even if somehow you bypassed the compile-time annotation check, the wire(), reset(), clearLsp(), and clearUi() methods do a runtime ClassLoader check. If a plugin class is on the call stack, a SecurityException is thrown. This is the defense-in-depth layer.
PluginApi uses @Volatile fields and @Synchronized methods for the internal wiring. As a plugin developer, all the public-facing APIs are safe to call from any thread. The implementations handle thread marshaling.
fun myPluginAction(context: PluginContext) { val editor = PluginApi.editor if (!editor.isAvailable()) return val selected = editor.getSelectedText() if (selected == null) { // Nothing selected, show a prompt or work on whole file val all = editor.getText() val processed = processText(all) editor.setText(processed) } else { // Work on the selection editor.deleteSelection() editor.insertText(transform(selected)) } }
fun onFileOpened(file: File, context: PluginContext) { val lsp = PluginApi.lsp ?: return val langId = lsp.detectLanguage(file) ?: return if (!lsp.isServerRunning(langId)) { lsp.startServer(langId) } lsp.openDocument(file) }
fun runLinter(file: File): String { val env = PluginApi.environment val proc = PluginApi.process .builder() .command("/usr/local/bin/my-linter", "--json", file.absolutePath) .attachStorage() .withEnv(mapOf("LANG" to "en_US.UTF-8")) .launch() val output = proc.inputStream.bufferedReader().readText() proc.waitFor() return output }
fun analyzeAndShow(context: PluginContext) { val result = runSomeAnalysis() context.runOnUiThread { PluginApi.ui?.showOverlay { handle -> ResultDialog( result = result, onInsert = { PluginApi.editor.insertText(result.suggestion) handle.dismiss() }, onDismiss = { handle.dismiss() } ) } } }
fun registerMyTemplate() { PluginApi.templates.registerTemplate( TemplateSpec( displayName = "MVVM Activity", templateType = "ACTIVITY", onCreate = { context, options, rawOptions -> val structure = PluginApi.templates.createStandardStructure( projectDir = options.saveLocation, packageId = options.packageId ) val pkg = options.packageId val name = options.projectName PluginApi.templates.createFile( dir = structure.javaDir, fileName = "${name}Activity.kt", content = buildActivityContent(pkg, name) ) PluginApi.templates.createFile( dir = structure.javaDir, fileName = "${name}ViewModel.kt", content = buildViewModelContent(pkg, name) ) PluginApi.templates.createFile( dir = structure.layoutDir, fileName = "activity_${name.lowercase()}.xml", content = buildLayoutContent() ) } ) ) }
Just a quick list of common mistakes to avoid.
Don't call @InternalPluginApi methods. They're not for you, they'll fail at compile time or throw SecurityException at runtime.
// Bad - won't compile PluginApi.wire(...) PluginApi.reset()
Don't use PluginApi.lsp or PluginApi.ui without null-checking. They are null outside EditorActivity.
// Bad - will throw NullPointerException if called from home screen PluginApi.lsp.startServer("kotlin") // Good PluginApi.lsp?.startServer("kotlin")
Don't try to cast PluginContext.applicationContext to anything. You'll get a PluginSandboxContext, not a real Application or Activity.
// Bad - ClassCastException val app = context.applicationContext as Application
Don't call getSystemService() with services outside the whitelist. It throws SecurityException.
// Bad context.getSystemService(Context.WINDOW_SERVICE) // not allowed
Don't hold long-lived references to PluginContext beyond your action's scope. The IDE clears things when activities are destroyed, and you don't want to hold a stale reference.
Don't call PluginApi.editor or PluginApi.environment before the IDE boots. In practice this won't happen during normal plugin actions, but don't call them from static initializers or module-level code that could run before the IDE is ready.
Don't call showOverlay() from a background thread. Use context.runOnUiThread to get onto the main thread first.
// Bad - calling from a background coroutine PluginApi.ui?.showOverlay { ... } // might crash if not on main thread // Good context.runOnUiThread { PluginApi.ui?.showOverlay { ... } }
| API | Access | Nullable | Available |
|---|---|---|---|
IEditorApi |
PluginApi.editor |
No | Always |
IEnvironmentApi |
PluginApi.environment |
No | Always |
ILspApi |
PluginApi.lsp |
Yes | EditorActivity only |
ITemplateApi |
PluginApi.templates |
No | Always |
IProcessApi |
PluginApi.process |
No | Always |
IUiApi |
PluginApi.ui |
Yes | EditorActivity only |
| Method | Description |
|---|---|
isAvailable() |
Check if editor is open and ready |
getText() |
Get full file content |
setText(text) |
Replace all file content |
getCurrentLine() |
0-indexed cursor line |
getCurrentColumn() |
0-indexed cursor column |
getCurrentLineDisplay() |
1-indexed cursor line |
getCurrentColumnDisplay() |
1-indexed cursor column |
setCursor(line, column) |
Move cursor (0-indexed) |
hasSelection() |
True if something is selected |
getSelectedText() |
Get selected text or null |
selectAll() |
Select everything |
copy() |
Copy selection to clipboard |
cut() |
Cut selection to clipboard |
paste() |
Paste at cursor |
canUndo() / canRedo() |
Check undo/redo state |
undo() / redo() |
Undo or redo |
insertText(text) |
Insert at cursor |
deleteSelection() |
Delete selected text |
formatDocument() |
Format via language server |
| Property | Description |
|---|---|
openProjectDir |
Current open project (nullable) |
projectsDir |
External storage projects dir |
acsRootProjects |
Internal storage projects dir |
androidSdkDir |
Android SDK location |
flutterDir |
Flutter SDK location |
filesDir |
App internal files dir |
homeDir |
Home dir inside internal storage |
localDir |
Local env dir |
tmpDir |
Temp dir |
rootfsAndroidSdkPath |
SDK path inside proot |
rootfsJavaHome |
JAVA_HOME inside proot |
| Method | Description |
|---|---|
hasServer(languageId) |
Is a server registered? |
isServerRunning(languageId) |
Is it currently running? |
getAvailableServers() |
All registered language IDs |
getRunningServers() |
Currently active language IDs |
startServer(languageId) |
Start a server |
stopServer(languageId) |
Stop a server |
stopAllServers() |
Stop everything |
openDocument(file) |
Notify file opened |
closeDocument(file) |
Notify file closed |
documentChanged(file, content, version) |
Notify file changed |
detectLanguage(file) |
Get language ID for file |
detectLanguage(fileName) |
Get language ID by filename |
registerServer(languageId, spec) |
Register your server |
unregisterServer(languageId) |
Remove your server |
registerExtension(ext, languageId) |
Map file extension to language |
unregisterExtension(ext) |
Unmap extension |
getRegisteredExtensions() |
Current extension map |
That's the whole API. If something isn't covered here, it's probably intentional (meaning it's either internal IDE code or not part of the stable plugin surface). When in doubt, work with what's in this guide. You can always check the official AndroidCS Plugins repository (https://github.com/AndroidCSIDE/androidcs-plugins/tree/main/official) to see how templates and LSPs are registered and created, which can help you better understand the process.