Skip to content

Repository files navigation

Android Code Studio - Plugin API Developer Guide

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.


Table of Contents

  1. Getting Started
  2. PluginApi - The Entry Point
  3. PluginContext - Your Sandboxed Context
  4. IEditorApi - Working with the Editor
  5. IEnvironmentApi - Paths and Environment
  6. ILspApi - Language Server Protocol
  7. IProcessApi - Launching Processes
  8. ITemplateApi - Project and File Templates
  9. IUiApi - Custom Compose UI Overlays
  10. Security Model
  11. Common Patterns and Examples
  12. Things You Should Never Do

Getting Started

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.

Add the API to your plugin's Gradle build file

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.

importcom.nullij.androidcodestudio.plugins.api.PluginApi// That's it. Just use it.val editor =PluginApi.editor
val env =PluginApi.environment

Important: All accessors throw IllegalStateException if 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 - The Entry Point

PluginApi is a Kotlin singleton (object) that gives you access to all the sub-APIs.

object PluginApi {
val editor:IEditorApival environment:IEnvironmentApival lsp:ILspApi?// null outside EditorActivityval templates:ITemplateApival process:IProcessApival ui:IUiApi?// null outside EditorActivity
}

Which ones can be null?

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 patternPluginApi.lsp?.let { lsp ->// We're inside the editor, lsp is available
lsp.startServer("kotlin")
}
// Also fineval 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.


PluginContext - Your Sandboxed Context

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.

What you can do with PluginContext

// Get the app package nameval 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

System services you're allowed to use

getSystemService() is whitelisted. Only these work:

ServiceConstant
ClipboardContext.CLIPBOARD_SERVICE
ConnectivityContext.CONNECTIVITY_SERVICE
Input MethodContext.INPUT_METHOD_SERVICE
VibratorContext.VIBRATOR_SERVICE
NotificationsContext.NOTIFICATION_SERVICE

Anything else throws SecurityException. No sneaking around it by calling applicationContext.getSystemService() either, that's sandboxed too.

What you cannot do (and why)

The PluginSandboxContext (which is what applicationContext returns) blocks a bunch of things:

  • startActivity() - plugins can't launch activities
  • startService() / bindService() - no binding to services
  • sendBroadcast() - no sending broadcasts
  • registerReceiver() - no registering broadcast receivers
  • getContentResolver() - no raw content resolver access

If you need to interact with content or show new screens, use the dedicated IDE APIs instead.


IEditorApi - Working with the Editor

Access via PluginApi.editor. All methods are thread-safe, the implementation handles marshaling internally.

Checking if the editor is ready

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 messagereturn
}

Reading and writing text

val editor =PluginApi.editor
// Get the full file contentval content = editor.getText()
// Replace the entire file content (goes into undo history)
editor.setText("fun main() {\n println(\"Hello\")\n}\n")

Cursor position

Cursor positions are 0-indexed internally. The Display variants give you 1-indexed values for showing in a status bar.

// 0-indexedval 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)

Selection

if (editor.hasSelection()) {
val selected = editor.getSelectedText()
// Do something with selected text
}
// Select everything
editor.selectAll()

Clipboard operations

editor.copy() // copies selection, no-op if nothing selected
editor.cut() // cuts selection, no-op if nothing selected
editor.paste() // pastes at cursor

Undo / Redo

if (editor.canUndo()) editor.undo()
if (editor.canRedo()) editor.redo()

Inserting and deleting text

// 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()

Formatting

// Triggers the language server formatter asynchronously
editor.formatDocument()

IEnvironmentApi - Paths and Environment

Access via PluginApi.environment. Gives you the file paths the IDE uses internally. Everything is read-only from the plugin side.

Project directories

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 storageval projects = env.projectsDir // ~/AndroidCSProjects// Internal storage projects rootval acsRoot = env.acsRootProjects // $HOME/AndroidCSProjects (internal)

SDK and toolchain paths

val sdk = env.androidSdkDir // $HOME/Android/Sdkval flutter = env.flutterDir // $HOME/flutter

App-private directories

val files = env.filesDir // Context.filesDirval home = env.homeDir // $filesDir/homeval local = env.localDir // $filesDir/localenvval tmp = env.tmpDir // $localDir/tmp

Rootfs paths (for processes running inside proot)

These are string paths as they appear inside the proot environment, not host filesystem paths.

val sdkInRootfs = env.rootfsAndroidSdkPath // Android SDK path inside rootfsval javaHome = env.rootfsJavaHome // JAVA_HOME inside rootfs

Environment variables

// Get the full env map the IDE uses for subprocessesval envMap = env.getEnvironment()
// Merge in your own overridesval envMap = env.getEnvironment(
additionalEnv =mapOf("MY_VAR" to "my_value")
)

Checking initialization status

if (!env.isInitialized()) {
// IDE environment isn't ready yetreturn
}

ILspApi - Language Server Protocol

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.

Checking server status

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>

Starting and stopping servers

val started = lsp.startServer("kotlin") // returns true if started or already running
lsp.stopServer("kotlin")
lsp.stopAllServers()

Document events

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

Detecting language from a file

val langId = lsp.detectLanguage(file) // from a File objectval langId2 = lsp.detectLanguage("Main.kt") // from a filename string// Returns null if unrecognized

Registering your own language server

This is the main extension point for plugins that want to add support for a new language. Implement PluginLanguageServerSpec and register it.

classMyLspServer : PluginLanguageServerSpec {
overrideval languageId ="mylang"overridefunstart(): Boolean {
// Start your LSP process herereturntrue
}
overridefunstop() {
// Stop it
}
overridefunisRunning(): Boolean=/* ... */overridefungetClient(): LanguageServerClient {
// Return a LanguageServerClient wrapper around your actual clientreturn myClient
}
overridefunopenDocument(file:File): Boolean {
// Notify your serverreturntrue
}
overridefuncloseDocument(file:File) { /* ... */ }
overridefundocumentChanged(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 registrationsval extensions = lsp.getRegisteredExtensions() // Map<String, String>// Clean up
lsp.unregisterServer("mylang")
lsp.unregisterExtension("ml")

IProcessApi - Launching Processes

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.

Basic usage

val process =PluginApi.process
.builder()
.command("/bin/bash-language-server", "start")
.attachStorage()
.withEnv(mapOf("HOME" to "/root"))
.launch()

Builder methods

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()

Working with the returned Process

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 stdoutval reader = proc.inputStream.bufferedReader()
// Wait for it to finishval exitCode = proc.waitFor()
// Or kill it
proc.destroy()

ITemplateApi - Project and File Templates

Access via PluginApi.templates. Lets you add new entries to the New Project / New File wizard.

Registering a template

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 fieldsval structure =PluginApi.templates.createStandardStructure(
projectDir = options.saveLocation,
packageId = options.packageId
)
// Write your template filesPluginApi.templates.createFile(
dir = structure.layoutDir,
fileName ="activity_my_custom.xml",
content = myLayoutXml
)
}
)
)
// Later, when your plugin is being cleaned upPluginApi.templates.unregisterTemplate(handle)

Template types

Common values for templateType:

  • "ACTIVITY" - a new Activity
  • "FRAGMENT" - a new Fragment
  • "PROJECT" - a full project

TemplateOptionsData

When your onCreate lambda fires, you get a TemplateOptionsData with all the user's choices:

data classTemplateOptionsData(
valprojectName:String, // What the user named the projectvalpackageId:String, // e.g. "com.example.myapp"valminSdk:Int, // Minimum SDK version chosenvaluseKts:Boolean, // Whether to use Kotlin Script build filesvalsaveLocation:File, // Where to create the projectvallanguageType:String, // "KOTLIN" or "JAVA"valcustomFields:Map<String, String> = emptyMap(), // Template-specific wizard values
)

File and directory helpers

val templates =PluginApi.templates
// Create a chain of directories, returns the leaf dirval dir = templates.createDirectories(
baseDir = someDir,
"src", "main", "kotlin", "com", "example"
)
// Write a fileval file = templates.createFile(
dir = dir,
fileName ="MainActivity.kt",
content =""" package com.example class MainActivity : AppCompatActivity()""".trimIndent()
)
// Build the whole standard Android project structure at onceval structure = templates.createStandardStructure(
projectDir = options.saveLocation,
packageId = options.packageId
)
// structure.projectDir, .mainSrcDir, .javaDir, .resDir, .layoutDir,// .valuesDir, .drawableDir, .manifestFile, .packageId, .packagePath

ProjectStructure fields

data classProjectStructure(
valprojectDir:File,
valmainSrcDir:File, // src/mainvaljavaDir:File, // src/main/java (or kotlin)valresDir:File, // src/main/resvallayoutDir:File, // src/main/res/layoutvalvaluesDir:File, // src/main/res/valuesvaldrawableDir:File, // src/main/res/drawablevalmanifestFile:File, // src/main/AndroidManifest.xmlvalpackageId:String,
valpackagePath:String// e.g. "com/example/myapp"
)

Reading extra options

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
}

IUiApi - Custom Compose UI Overlays

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.

Showing an overlay

val ui =PluginApi.ui ?:returnval handle = ui.showOverlay { overlayHandle ->// This is a @Composable lambdaMyPluginDialog(
onConfirm = { /* do stuff */ overlayHandle.dismiss() },
onCancel = { overlayHandle.dismiss() }
)
}

Dismissing from outside

// If you held on to the handle
handle.dismiss()
// Or dismiss everything at once
ui.dismissAll()

OverlayHandle

interfaceOverlayHandle {
fundismiss() // Remove from composition. Safe to call multiple times.
val isShowing:Boolean// True while still visible
}

Threading

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() })
}
}

Security Model

The plugin system has multiple layers of protection. Here's a quick rundown so you understand why certain things don't work.

PluginContext sandboxing

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).

@InternalPluginApi annotation

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 codePluginApi.templates.callListenerMethod(...) // compile error// This tooPluginApi.wire(...) // not even in your API surface

Runtime ClassLoader check

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.

Thread-safety

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.


Common Patterns and Examples

Check-then-act pattern for editor plugins

funmyPluginAction(context:PluginContext) {
val editor =PluginApi.editor
if (!editor.isAvailable()) returnval selected = editor.getSelectedText()
if (selected ==null) {
// Nothing selected, show a prompt or work on whole fileval all = editor.getText()
val processed = processText(all)
editor.setText(processed)
} else {
// Work on the selection
editor.deleteSelection()
editor.insertText(transform(selected))
}
}

Starting a language server when a file opens

funonFileOpened(file:File, context:PluginContext) {
val lsp =PluginApi.lsp ?:returnval langId = lsp.detectLanguage(file) ?:returnif (!lsp.isServerRunning(langId)) {
lsp.startServer(langId)
}
lsp.openDocument(file)
}

Creating a process and reading its output

funrunLinter(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
}

Showing a result in the editor with a dialog

funanalyzeAndShow(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() }
)
}
}
}

Registering a template with full structure

funregisterMyTemplate() {
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()
)
}
)
)
}

Things You Should Never Do

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 compilePluginApi.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 screenPluginApi.lsp.startServer("kotlin")
// GoodPluginApi.lsp?.startServer("kotlin")

Don't try to cast PluginContext.applicationContext to anything. You'll get a PluginSandboxContext, not a real Application or Activity.

// Bad - ClassCastExceptionval app = context.applicationContext asApplication

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 coroutinePluginApi.ui?.showOverlay { ... } // might crash if not on main thread// Good
context.runOnUiThread {
PluginApi.ui?.showOverlay { ... }
}

API Quick Reference

APIAccessNullableAvailable
IEditorApiPluginApi.editorNoAlways
IEnvironmentApiPluginApi.environmentNoAlways
ILspApiPluginApi.lspYesEditorActivity only
ITemplateApiPluginApi.templatesNoAlways
IProcessApiPluginApi.processNoAlways
IUiApiPluginApi.uiYesEditorActivity only

IEditorApi methods

MethodDescription
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

IEnvironmentApi properties

PropertyDescription
openProjectDirCurrent open project (nullable)
projectsDirExternal storage projects dir
acsRootProjectsInternal storage projects dir
androidSdkDirAndroid SDK location
flutterDirFlutter SDK location
filesDirApp internal files dir
homeDirHome dir inside internal storage
localDirLocal env dir
tmpDirTemp dir
rootfsAndroidSdkPathSDK path inside proot
rootfsJavaHomeJAVA_HOME inside proot

ILspApi methods

MethodDescription
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.

About

No description, website, or topics provided.

Resources

Stars

15 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages