diff --git a/README.md b/README.md
index 6e04a418..023c87d8 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego
| [`plugins/Get-AI-Models/`](plugins/Get-AI-Models/) | Downloads small language models for on-device AI addons. |
| [`plugins/Icons-Repository/`](plugins/Icons-Repository/) | Adds vector icons to a project from inside the editor. |
| [`plugins/Jetpack-Compose-Preview/`](plugins/Jetpack-Compose-Preview/) | Renders Compose preview functions on the device. |
+| [`plugins/JVM-Tools/`](plugins/JVM-Tools/) | Runs Java and Kotlin programs, with a main-class picker and program arguments. |
| [`plugins/Keystore-Generator/`](plugins/Keystore-Generator/) | Creates and manages app signing keystores on the device. |
| [`plugins/Layout-Editor/`](plugins/Layout-Editor/) | Edits Android XML layouts by dragging views. |
| [`plugins/Markdown-Previewer/`](plugins/Markdown-Previewer/) | Shows a live preview of Markdown and HTML files. |
diff --git a/plugins/JVM-Tools/.gitignore b/plugins/JVM-Tools/.gitignore
new file mode 100644
index 00000000..6af499b7
--- /dev/null
+++ b/plugins/JVM-Tools/.gitignore
@@ -0,0 +1,29 @@
+# Gradle
+.gradle/
+build/
+gradle-app.setting
+!gradle-wrapper.jar
+.gradletasknamecache
+
+# IDE
+.idea/
+*.iml
+*.ipr
+*.iws
+.project
+.classpath
+.settings/
+.kotlin/
+
+# Local configuration
+local.properties
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+
+# Test outputs
+test-results/
diff --git a/plugins/JVM-Tools/README.md b/plugins/JVM-Tools/README.md
new file mode 100644
index 00000000..7c880657
--- /dev/null
+++ b/plugins/JVM-Tools/README.md
@@ -0,0 +1,43 @@
+# JVM Tools
+
+Runs Java and Kotlin programs in Code on the Go, the way a desktop IDE does: choose the main class,
+give it program arguments, and tap Run.
+
+## What it adds
+
+In a JVM project, a folder of `.java` and `.kt` files with no Gradle build, the editor toolbar gets:
+
+- **Run** - compiles every Java and Kotlin file in the project on the device and runs the chosen main
+ class. Compiler errors and the program's output stream into Build Output, and the button becomes
+ **Cancel Run** while the program runs.
+- **Run configuration** - lists every class with a `main` method and takes the program arguments.
+ The choice is saved for each project.
+
+The Gradle actions (Quick Run, Quick Build, Sync, Debug, Run Tasks, Launch App) are hidden in a JVM
+project. Android projects are left alone.
+
+The **Java/Kotlin Program** template on the New Project screen creates a two-class program in
+`src/`, in the language you choose.
+
+## How it runs code
+
+Nothing is downloaded. Java is compiled with the JDK that ships with Code on the Go, and Kotlin with
+the `kotlin-compiler-embeddable` already in the IDE's local Maven repository. Classes go to a folder
+under `$PREFIX/tmp/jvm-tools`, not into the project.
+
+## Limits
+
+- Programs cannot read keyboard input; pass values as program arguments.
+- Only the Java class library and the Kotlin standard library are on the classpath.
+- Gradle projects are not handled.
+
+## Build
+
+```sh
+cd plugins/JVM-Tools
+../../gradlew assemblePlugin # build/plugin/jvm-tools.cgp
+../../gradlew assemblePluginDebug # build/plugin/jvm-tools-debug.cgp
+../../gradlew testDebugUnitTest
+```
+
+Supports Code on the Go 26.37 through 26.99.
diff --git a/plugins/JVM-Tools/addon.json b/plugins/JVM-Tools/addon.json
new file mode 100644
index 00000000..730d9f3c
--- /dev/null
+++ b/plugins/JVM-Tools/addon.json
@@ -0,0 +1,15 @@
+{
+ "summary": "Runs Java and Kotlin programs, with a main-class picker and program arguments.",
+ "description": "Compiles a folder of Java and Kotlin files on the device and runs the main class you choose, with the arguments you give it. Output appears in Build Output. Adds a Java/Kotlin Program template.",
+ "tags": [
+ "java",
+ "kotlin",
+ "templates"
+ ],
+ "origin": "appdevforall",
+ "license": "AGPL-3.0-or-later",
+ "author": {
+ "name": "App Dev For All",
+ "url": "https://www.appdevforall.org"
+ }
+}
diff --git a/plugins/JVM-Tools/build.gradle.kts b/plugins/JVM-Tools/build.gradle.kts
new file mode 100644
index 00000000..d9cd4185
--- /dev/null
+++ b/plugins/JVM-Tools/build.gradle.kts
@@ -0,0 +1,75 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ id("com.android.application")
+ id("com.itsaky.androidide.plugins.build")
+}
+
+pluginBuilder {
+ pluginName = "jvm-tools"
+}
+
+android {
+ namespace = "com.appdevforall.jvm.plugin"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.appdevforall.jvm.plugin"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ packaging {
+ resources {
+ excludes += setOf(
+ "META-INF/versions/9/OSGI-INF/MANIFEST.MF",
+ "META-INF/DEPENDENCIES",
+ "META-INF/LICENSE",
+ "META-INF/LICENSE.txt",
+ "META-INF/NOTICE",
+ "META-INF/NOTICE.txt"
+ )
+ }
+ }
+
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ compileOnly(files("../../libs/plugin-api.jar"))
+ compileOnly("com.google.android.material:material:1.12.0")
+
+ implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.21")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+
+ testImplementation("junit:junit:4.13.2")
+}
+
+tasks.matching {
+ it.name.contains("checkDebugAarMetadata") ||
+ it.name.contains("checkReleaseAarMetadata")
+}.configureEach {
+ enabled = false
+}
diff --git a/plugins/JVM-Tools/gradle.properties b/plugins/JVM-Tools/gradle.properties
new file mode 100644
index 00000000..9ba44411
--- /dev/null
+++ b/plugins/JVM-Tools/gradle.properties
@@ -0,0 +1,4 @@
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
diff --git a/plugins/JVM-Tools/jvm-tools.html b/plugins/JVM-Tools/jvm-tools.html
new file mode 100644
index 00000000..5e8fac90
--- /dev/null
+++ b/plugins/JVM-Tools/jvm-tools.html
@@ -0,0 +1,227 @@
+
+
+
+
+
+JVM Tools
+
+
+
+
+
+
+
JVM Tools
+
Compile and run Java and Kotlin programs on the device, with a main-class picker and program arguments
+
Version 1.0.0 · Author: App Dev For All · Package: com.appdevforall.jvm.plugin
+
+
+
+
+
+
1. Executive Overview
+
+ JVM Tools runs Java and Kotlin programs the way a desktop IDE does: choose the class whose
+ main should start, give it program arguments, and tap Run. The project is compiled
+ on the device with the JDK and Kotlin compiler that Code on the Go already ships, and the output
+ streams into Build Output. Nothing is downloaded.
+
+
+ It applies to JVM projects only: a folder of .java and .kt files with no
+ Gradle build. Android projects keep their Gradle toolbar untouched.
+
+
+
+
+
2. Core Functionality
+
+
Feature
What it does
+
Run
Compiles every Java and Kotlin file in the project and runs the chosen main class with its arguments. Becomes Cancel Run while the program runs.
+
Run configuration
Lists every class with a main method and takes the program arguments. Saved per project.
+
Java/Kotlin Program
A New Project template with Main and Greeter classes in Java or Kotlin.
+
+
+
+
+
3. Technical Architecture
+
+
Domain gating
+
+ JvmDomain treats the open project as JVM when its root has no Gradle build file and a
+ .java or .kt file sits within four folders of it. Every contribution
+ below is gated on that check, so the plugin is invisible in Android projects.
+
+
+
Finding main classes
+
+ MainClassFinder reads the sources with comments removed and reports Java classes with a
+ static void main(String[]), Kotlin files with a top-level fun main (as the
+ FileNameKt facade, or the @file:JvmName), and Kotlin objects or companion
+ objects with a @JvmStatic main. Names are qualified with the file's package.
+
+
+
Compiling and running
+
Run is a PluginBuildAction whose shell command:
+
+
lists the project's sources, skipping build/ and dot folders;
+
compiles all sources with K2JVMCompiler from kotlin-compiler-embeddable in the IDE's local Maven repository, when any .kt file exists (Java files are passed too, so Kotlin can see Java classes);
+
compiles the .java files with the JDK's javac against the Kotlin output;
+
replaces itself with java -cp classes[:kotlin-stdlib] MainClass args..., so cancelling the action stops the program.
+
+
+ Classes are written to a per-project folder under $PREFIX/tmp/jvm-tools, never into
+ the project. The toolchain paths come from IdeEnvironmentService.getTmpDirectory(),
+ whose parent is $PREFIX.
+
+
+
Hiding the Gradle actions
+
+ toolbarActionsToHide() returns ToolbarActionIds.BUILD_HIDEABLE, and
+ getHiddenToolbarActionIds() adds Quick Build, which is not in that set.
+
+
+
+
+
4. Integration Points
+
+
API
Used for
+
BuildActionExtension
The Run action and the pre-run save of unsaved Java and Kotlin buffers
+
UIExtension
The Run configuration toolbar button and hiding Quick Build
+
DocumentationExtension
Long-press tooltips and the in-app help page
+
IdeTemplateService
The Java/Kotlin Program template
+
IdeEditorService
getModifiedFiles, getFileContent and saveFile before a run
+
PluginWindows
Showing the Run configuration dialog, a Material dialog built on the IDE activity
+
+
+ Permissions: filesystem.read, filesystem.write,
+ system.commands, project.structure. Supports Code on the Go 26.37 through
+ 26.99; 26.37 is the first release with PluginWindows, which shows the Run configuration
+ dialog.
+
+
+
+
+
5. Usage
+
+
Install the plugin from the Plugin Manager and restart Code on the Go.
+
Create a project with Java/Kotlin Program on the New Project screen, or open a folder of .java and .kt files.
+
Tap Run. The project compiles and the output appears in Build Output.
+
To run another class or pass arguments, tap Run configuration, choose the main class, enter the arguments, and tap Save. Run uses that choice from then on.
+
+
Build the plugin itself with ../../gradlew assemblePlugin from plugins/JVM-Tools.
+
+
+
+
6. Key Benefits
+
+
Runs Java and Kotlin programs with no Gradle project, no network and no extra downloads.
+
Works like a desktop IDE: pick the main class and arguments once, then run with one tap.
+
Leaves Android projects untouched; the toolbar changes only in a JVM project.
+
+
+
+
+
7. Limits
+
+
No keyboard input: plugin commands have no stdin, so Scanner and readLine() see the end of input.
+
No third-party libraries: only the Java class library and the Kotlin standard library are on the classpath.
Run Java and Kotlin programs on the device, straight from the editor toolbar.
+
+
JVM Tools adds a project template and two toolbar commands. The commands appear only while a
+JVM project is open; in an Android project the toolbar is left alone. Because Gradle plays no part
+in a JVM project, the Gradle actions (Quick Run, Quick Build, Sync, Debug, Run Tasks, Launch App)
+are hidden while you work in one.
+
+
+
A project counts as JVM when it has no Gradle build file (build.gradle,
+build.gradle.kts, settings.gradle, or settings.gradle.kts)
+and holds at least one .java or .kt file within four folders of its
+root.
+
+
+
The commands
+
+
Run
+
Compiles every .java and .kt file in the project, then starts the main
+class chosen in Run configuration with its program arguments. Folders named build and
+folders whose names start with a dot are skipped. Kotlin is compiled first, so Java and Kotlin
+classes can use each other.
+
When no main class has been chosen, Run starts the main class of the file open in the editor,
+or else the first main class in alphabetical order.
+
Compiler errors and the program's output stream into Build Output. While the program runs,
+the button becomes Cancel Run; tap it to stop the program. A run is stopped after 30
+minutes. Unsaved changes in open Java and Kotlin files are saved before compiling.
+
+
Run configuration
+
Chooses which main class Run starts and the program arguments it passes. The list holds every
+class with a main method:
+
+
a Java class with public static void main(String[] args)
+
a Kotlin file with a top-level fun main(), which runs as
+ FileNameKt
+
a Kotlin object or companion object with a @JvmStatic
+ main
+
+
Program arguments are split on spaces. Wrap an argument in single or double quotes to keep its
+spaces, for example --name "Ada Lovelace". The choice is saved for each project.
+
+
The project template
+
Java/Kotlin Program on the New Project screen creates a program in
+src/: a Main class that prints a greeting built by a
+Greeter class. Choose Java or Kotlin when you create it, then tap Run.
+
+
What it cannot do
+
+
The program cannot read keyboard input: Scanner and readLine()
+ see the end of input. Pass values as program arguments instead.
+
Libraries are not downloaded. A program can use the Java class library and, for Kotlin, the
+ Kotlin standard library.
+
Gradle projects are not handled; build and run them with the IDE's own Gradle actions.
+
+
+
Turning it off
+
Disable JVM Tools in the Plugin Manager. The two commands disappear, the Gradle actions come
+back for every project, and the template is removed from the New Project screen.
+
+
+
diff --git a/plugins/JVM-Tools/src/main/assets/icon_day.png b/plugins/JVM-Tools/src/main/assets/icon_day.png
new file mode 100644
index 00000000..7dcdcbc2
Binary files /dev/null and b/plugins/JVM-Tools/src/main/assets/icon_day.png differ
diff --git a/plugins/JVM-Tools/src/main/assets/icon_night.png b/plugins/JVM-Tools/src/main/assets/icon_night.png
new file mode 100644
index 00000000..74d7bf5f
Binary files /dev/null and b/plugins/JVM-Tools/src/main/assets/icon_night.png differ
diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.java.peb b/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.java.peb
new file mode 100644
index 00000000..88f00446
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.java.peb
@@ -0,0 +1,5 @@
+public class Greeter {
+ public static String greet(String name) {
+ return "Hello, " + name + "! This program runs on your device.";
+ }
+}
diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.kt.peb b/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.kt.peb
new file mode 100644
index 00000000..8b6323a0
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/assets/templates/console/Greeter.kt.peb
@@ -0,0 +1,3 @@
+object Greeter {
+ fun greet(name: String) = "Hello, $name! This program runs on your device."
+}
diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/Main.java.peb b/plugins/JVM-Tools/src/main/assets/templates/console/Main.java.peb
new file mode 100644
index 00000000..540236e5
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/assets/templates/console/Main.java.peb
@@ -0,0 +1,6 @@
+public class Main {
+ public static void main(String[] args) {
+ String name = args.length > 0 ? String.join(" ", args) : "world";
+ System.out.println(Greeter.greet(name));
+ }
+}
diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/Main.kt.peb b/plugins/JVM-Tools/src/main/assets/templates/console/Main.kt.peb
new file mode 100644
index 00000000..84e72b38
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/assets/templates/console/Main.kt.peb
@@ -0,0 +1,4 @@
+fun main(args: Array) {
+ val name = if (args.isNotEmpty()) args.joinToString(" ") else "world"
+ println(Greeter.greet(name))
+}
diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/README.md b/plugins/JVM-Tools/src/main/assets/templates/console/README.md
new file mode 100644
index 00000000..f5ff2d60
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/assets/templates/console/README.md
@@ -0,0 +1,8 @@
+# Java/Kotlin program
+
+A command-line program that runs on your device.
+
+- Tap **Run** to compile the project and run `Main`. The output appears in Build Output.
+- Tap **Run configuration** to choose another main class or to pass program arguments,
+ for example `Ada Lovelace`.
+- Add more classes under `src/`. Every Java and Kotlin file in the project is compiled together.
diff --git a/plugins/JVM-Tools/src/main/assets/templates/console/thumb.png b/plugins/JVM-Tools/src/main/assets/templates/console/thumb.png
new file mode 100644
index 00000000..c781d606
Binary files /dev/null and b/plugins/JVM-Tools/src/main/assets/templates/console/thumb.png differ
diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmDomain.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmDomain.kt
new file mode 100644
index 00000000..ed5fabea
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmDomain.kt
@@ -0,0 +1,32 @@
+package com.appdevforall.jvm.plugin
+
+import java.io.File
+
+internal object JvmDomain {
+
+ private val GRADLE_MARKERS = listOf("settings.gradle", "settings.gradle.kts", "build.gradle", "build.gradle.kts")
+ private const val SCAN_DEPTH = 4
+
+ @Volatile
+ private var cachedRoot: String? = null
+
+ @Volatile
+ private var cachedResult = false
+
+ @Synchronized
+ fun isJvmProject(root: File?): Boolean {
+ if (root == null) return false
+ val path = root.absolutePath
+ if (path != cachedRoot) {
+ cachedResult = GRADLE_MARKERS.none { File(root, it).exists() } && hasJvmSource(root, SCAN_DEPTH)
+ cachedRoot = path
+ }
+ return cachedResult
+ }
+
+ private fun hasJvmSource(dir: File, depth: Int): Boolean {
+ val children = dir.listFiles() ?: return false
+ if (children.any { it.isFile && MainClassFinder.isSource(it) }) return true
+ return depth > 0 && children.any { it.isDirectory && !MainClassFinder.isSkipped(it) && hasJvmSource(it, depth - 1) }
+ }
+}
diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsDocumentation.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsDocumentation.kt
new file mode 100644
index 00000000..be122691
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsDocumentation.kt
@@ -0,0 +1,57 @@
+package com.appdevforall.jvm.plugin
+
+import com.itsaky.androidide.plugins.extensions.PluginTooltipButton
+import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry
+
+internal object JvmToolsDocumentation {
+
+ const val CATEGORY = "plugin_${JvmToolsPlugin.PLUGIN_ID}"
+ const val DOCS_ASSET_PATH = "docs"
+
+ fun entries(): List = listOf(
+ PluginTooltipEntry(
+ tag = tagFor(JvmToolsPlugin.ACTION_RUN),
+ summary = "Compiles every Java and Kotlin file in the project and runs the chosen main class.",
+ detail = """
+
Compiles all .java and .kt files in the project on the device,
+ then starts the main class chosen in Run configuration with its program arguments.
+ With no choice saved, Run starts the main class of the file open in the editor, or the
+ first one it finds.
+
Compiler errors and the program's output stream into Build Output. While the
+ program runs, the button becomes Cancel Run; tap it to stop the program. A run is
+ stopped after 30 minutes.
+
The program cannot read keyboard input: Scanner and
+ readLine() see the end of input. Pass values as program arguments instead.
+ """.trimIndent(),
+ buttons = buttons("run"),
+ ),
+ PluginTooltipEntry(
+ tag = tagFor(JvmToolsPlugin.ACTION_RUN_CONFIGURATION),
+ summary = "Chooses which main class Run starts and the arguments it passes.",
+ detail = """
+
Lists every class in the project that has a main method: Java classes with
+ public static void main(String[] args), Kotlin files with a top-level
+ fun main(), and Kotlin objects with a @JvmStatic main.
+
Program arguments are split on spaces. Wrap an argument in quotes to keep its spaces,
+ for example --name "Ada Lovelace". The choice is saved for this project.
+ """.trimIndent(),
+ buttons = buttons("run-configuration"),
+ ),
+ )
+
+ fun tagFor(actionId: String): String = "${JvmToolsPlugin.PLUGIN_ID}.$actionId"
+
+ private fun buttons(anchor: String): List = listOf(
+ PluginTooltipButton(
+ description = "How this command works",
+ uri = "index.html#$anchor",
+ order = 0,
+ ),
+ PluginTooltipButton(
+ description = "About Code on the Go plugins",
+ uri = "i/plugins-adfa.html",
+ order = 1,
+ directPath = true,
+ ),
+ )
+}
diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsPlugin.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsPlugin.kt
new file mode 100644
index 00000000..d21a476c
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/JvmToolsPlugin.kt
@@ -0,0 +1,310 @@
+package com.appdevforall.jvm.plugin
+
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageButton
+import com.itsaky.androidide.plugins.IPlugin
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.extensions.BuildActionCategory
+import com.itsaky.androidide.plugins.extensions.BuildActionExtension
+import com.itsaky.androidide.plugins.extensions.CommandSpec
+import com.itsaky.androidide.plugins.extensions.DocumentationExtension
+import com.itsaky.androidide.plugins.extensions.PluginBuildAction
+import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry
+import com.itsaky.androidide.plugins.extensions.ToolbarAction
+import com.itsaky.androidide.plugins.extensions.ToolbarActionIds
+import com.itsaky.androidide.plugins.extensions.UIExtension
+import com.itsaky.androidide.plugins.services.IdeEditorService
+import com.itsaky.androidide.plugins.services.IdeEnvironmentService
+import com.itsaky.androidide.plugins.services.IdeProjectService
+import com.itsaky.androidide.plugins.services.IdeTemplateService
+import com.itsaky.androidide.plugins.services.IdeTooltipService
+import com.itsaky.androidide.plugins.services.IdeUIService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.util.concurrent.atomic.AtomicBoolean
+
+class JvmToolsPlugin : IPlugin, BuildActionExtension, UIExtension, DocumentationExtension {
+
+ private var pluginContext: PluginContext? = null
+ private var projectService: IdeProjectService? = null
+ private var editorService: IdeEditorService? = null
+ private var environmentService: IdeEnvironmentService? = null
+ private var templateService: IdeTemplateService? = null
+ private var uiService: IdeUIService? = null
+ private var tooltipService: IdeTooltipService? = null
+ private var configurations: RunConfigurationStore? = null
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private val registeredTemplates = mutableListOf()
+ private val tooltipBindingScheduled = AtomicBoolean(false)
+
+ override fun initialize(context: PluginContext): Boolean {
+ pluginContext = context
+ projectService = context.services.get(IdeProjectService::class.java)
+ editorService = context.services.get(IdeEditorService::class.java)
+ environmentService = context.services.get(IdeEnvironmentService::class.java)
+ templateService = context.services.get(IdeTemplateService::class.java)
+ uiService = context.services.get(IdeUIService::class.java)
+ tooltipService = context.services.get(IdeTooltipService::class.java)
+ configurations = RunConfigurationStore(File(context.resources.getPluginDirectory(), CONFIGURATIONS_FILE))
+ return true
+ }
+
+ override fun activate(): Boolean {
+ registerTemplates()
+ return true
+ }
+
+ override fun deactivate(): Boolean {
+ templateService?.let { service -> registeredTemplates.forEach { service.unregisterTemplate(it) } }
+ registeredTemplates.clear()
+ return true
+ }
+
+ override fun dispose() {
+ scope.cancel()
+ pluginContext = null
+ projectService = null
+ editorService = null
+ environmentService = null
+ templateService = null
+ uiService = null
+ tooltipService = null
+ configurations = null
+ }
+
+ override fun toolbarActionsToHide(): Set {
+ if (!isJvmProjectOpen()) return emptySet()
+ scheduleTooltipBinding()
+ return ToolbarActionIds.BUILD_HIDEABLE
+ }
+
+ override fun getHiddenToolbarActionIds(): Set =
+ if (isJvmProjectOpen()) setOf(QUICK_BUILD_ACTION_ID) else emptySet()
+
+ override fun getToolbarActions(): List = listOf(
+ ToolbarAction(
+ id = ACTION_RUN_CONFIGURATION,
+ title = RUN_CONFIGURATION_LABEL,
+ icon = R.drawable.ic_run_configuration,
+ order = RUN_CONFIGURATION_ORDER,
+ action = ::showRunConfiguration,
+ ).apply { isVisibleProvider = ::isJvmProjectOpen }
+ )
+
+ override fun getBuildActions(): List {
+ if (!isJvmProjectOpen()) return emptyList()
+ val root = projectService?.getCurrentProject()?.rootDir ?: return emptyList()
+ val toolchain = resolveToolchain() ?: return emptyList()
+ scheduleTooltipBinding()
+ val configuration = effectiveConfiguration(root, MainClassFinder.find(root))
+ return listOf(
+ PluginBuildAction(
+ id = ACTION_RUN,
+ name = RUN_LABEL,
+ description = configuration?.let { "Compile the project and run ${it.mainClass}" }
+ ?: "Compile the project and run its main class",
+ icon = R.drawable.ic_run_jvm,
+ category = BuildActionCategory.BUILD,
+ command = CommandSpec.ShellCommand("sh", listOf("-c", runScript(root, toolchain, configuration))),
+ timeoutMs = RUN_TIMEOUT_MS,
+ )
+ )
+ }
+
+ override fun onActionStarted(actionId: String) {
+ if (actionId != ACTION_RUN) return
+ val editor = editorService ?: return
+ val root = projectService?.getCurrentProject()?.rootDir?.canonicalFile ?: return
+ val unsaved = editor.getModifiedFiles().filter { MainClassFinder.isSource(it) && it.canonicalFile.startsWith(root) }
+ unsaved.forEach { file -> editor.getFileContent(file)?.let(file::writeText) }
+ scope.launch { unsaved.forEach { editor.saveFile(it) } }
+ }
+
+ override fun getTooltipCategory(): String = JvmToolsDocumentation.CATEGORY
+
+ override fun getTooltipEntries(): List = JvmToolsDocumentation.entries()
+
+ override fun getTier3DocsAssetPath(): String = JvmToolsDocumentation.DOCS_ASSET_PATH
+
+ private fun isJvmProjectOpen(): Boolean = JvmDomain.isJvmProject(projectService?.getCurrentProject()?.rootDir)
+
+ private fun effectiveConfiguration(root: File, mainClasses: List): RunConfiguration? {
+ val saved = configurations?.get(root)
+ if (saved != null && mainClasses.any { it.name == saved.mainClass }) return saved
+ val current = editorService?.getCurrentFile()?.canonicalFile
+ val main = mainClasses.firstOrNull { it.source.canonicalFile == current } ?: mainClasses.firstOrNull() ?: return null
+ return RunConfiguration(main.name, saved?.arguments ?: "")
+ }
+
+ private fun showRunConfiguration() {
+ val root = projectService?.getCurrentProject()?.rootDir ?: return
+ val activity = uiService?.takeIf { it.isUIAvailable() }?.getCurrentActivity() ?: return
+ scope.launch {
+ val mainClasses = withContext(Dispatchers.IO) { MainClassFinder.find(root) }
+ val selected = effectiveConfiguration(root, mainClasses) ?: RunConfiguration("", "")
+ RunConfigurationDialog.show(
+ activity,
+ mainClasses,
+ selected,
+ onHelp = { view ->
+ tooltipService?.showTooltip(
+ view,
+ JvmToolsDocumentation.CATEGORY,
+ JvmToolsDocumentation.tagFor(ACTION_RUN_CONFIGURATION),
+ )
+ },
+ ) { configuration ->
+ scope.launch(Dispatchers.IO) { configurations?.put(root, configuration) }
+ }
+ }
+ }
+
+ private fun registerTemplates() {
+ val service = templateService ?: return
+ val context = pluginContext ?: return
+ val template = service.createTemplateBuilder(TEMPLATE_NAME)
+ .description("A command-line program in Java or Kotlin. Choose the language when you create it, then tap Run.")
+ .showLanguageOption()
+ .thumbnailFromAssets("templates/console/thumb.png", context)
+ .addTemplateFromAssets("src/Main.java", "templates/console/Main.java.peb", context)
+ .addTemplateFromAssets("src/Greeter.java", "templates/console/Greeter.java.peb", context)
+ .addTemplateFromAssets("src/Main.kt", "templates/console/Main.kt.peb", context)
+ .addTemplateFromAssets("src/Greeter.kt", "templates/console/Greeter.kt.peb", context)
+ .addStaticFromAssets("README.md", "templates/console/README.md", context)
+ .build(context.resources.getPluginDirectory())
+ if (service.registerTemplate(template)) registeredTemplates += template.name
+ }
+
+ private fun resolveToolchain(): Toolchain? {
+ val tmp = environmentService?.getTmpDirectory() ?: return null
+ val prefix = tmp.parentFile ?: return null
+ val jdk = File(prefix, "lib/jvm/java-21-openjdk/bin")
+ return Toolchain(
+ java = File(jdk, "java"),
+ javac = File(jdk, "javac"),
+ mavenRepository = File(prefix.parentFile, "home/maven/localMvnRepository"),
+ workDirectory = File(tmp, "jvm-tools"),
+ )
+ }
+
+ private fun runScript(root: File, toolchain: Toolchain, configuration: RunConfiguration?): String = buildString {
+ append("JAVA=").append(shellQuote(toolchain.java.absolutePath)).append('\n')
+ append("JAVAC=").append(shellQuote(toolchain.javac.absolutePath)).append('\n')
+ append("MAVEN=").append(shellQuote(toolchain.mavenRepository.absolutePath)).append('\n')
+ append("PROJECT=").append(shellQuote(root.absolutePath)).append('\n')
+ append("WORK=").append(shellQuote(File(toolchain.workDirectory, root.absolutePath.hashCode().toUInt().toString(16)).absolutePath)).append('\n')
+ append("MAIN=").append(shellQuote(configuration?.mainClass ?: "")).append('\n')
+ append("set --")
+ ProgramArguments.parse(configuration?.arguments ?: "").forEach { append(' ').append(shellQuote(it)) }
+ append('\n')
+ append(RUN_SCRIPT)
+ }
+
+ private fun shellQuote(value: String): String = "'" + value.replace("'", "'\\''") + "'"
+
+ private fun scheduleTooltipBinding() {
+ if (tooltipService == null) return
+ val activity = uiService?.takeIf { it.isUIAvailable() }?.getCurrentActivity() ?: return
+ if (!tooltipBindingScheduled.compareAndSet(false, true)) return
+ activity.runOnUiThread {
+ val decor = activity.window?.decorView
+ if (decor == null) {
+ tooltipBindingScheduled.set(false)
+ return@runOnUiThread
+ }
+ decor.post {
+ tooltipBindingScheduled.set(false)
+ (decor as? ViewGroup)?.let(::bindTooltipsIn)
+ }
+ }
+ }
+
+ private fun bindTooltipsIn(group: ViewGroup) {
+ for (index in 0 until group.childCount) {
+ when (val child = group.getChildAt(index)) {
+ is ImageButton -> bindTooltip(child)
+ is ViewGroup -> bindTooltipsIn(child)
+ }
+ }
+ }
+
+ private fun bindTooltip(button: ImageButton) {
+ val tooltips = tooltipService ?: return
+ if (button.contentDescription?.toString() !in RUN_BUTTON_LABELS) return
+ button.setOnLongClickListener { view: View ->
+ tooltips.showTooltip(view, JvmToolsDocumentation.CATEGORY, JvmToolsDocumentation.tagFor(ACTION_RUN))
+ true
+ }
+ }
+
+ private data class Toolchain(val java: File, val javac: File, val mavenRepository: File, val workDirectory: File)
+
+ companion object {
+ internal const val PLUGIN_ID = "com.appdevforall.jvm.plugin"
+ internal const val ACTION_RUN = "jvm.run"
+ internal const val ACTION_RUN_CONFIGURATION = "jvm.runConfiguration"
+
+ private const val RUN_LABEL = "Run"
+ private const val RUN_CONFIGURATION_LABEL = "Run configuration"
+ private val RUN_BUTTON_LABELS = setOf(RUN_LABEL, "Cancel $RUN_LABEL")
+ private const val RUN_CONFIGURATION_ORDER = 14
+ private const val RUN_TIMEOUT_MS = 30 * 60_000L
+ private const val QUICK_BUILD_ACTION_ID = "ide.editor.build.quickBuild"
+ private const val CONFIGURATIONS_FILE = "run-configurations.json"
+ private const val TEMPLATE_NAME = "Java/Kotlin Program"
+
+ private val RUN_SCRIPT: String = """
+ set -u
+ if [ -z "§MAIN" ]; then
+ echo "No main class found. Add a Java class with public static void main(String[] args), or a Kotlin file with a top-level fun main()." >&2
+ exit 2
+ fi
+ if [ ! -x "§JAVA" ] || [ ! -x "§JAVAC" ]; then
+ echo "The device JDK is missing from §(dirname "§JAVA")." >&2
+ exit 3
+ fi
+ cd "§PROJECT" || exit 2
+ rm -rf "§WORK"
+ mkdir -p "§WORK/classes"
+ find . \( -name build -o -name '.?*' \) -prune -o -type f \( -name '*.java' -o -name '*.kt' \) -print | sort > "§WORK/sources"
+ quote() { sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/.*/"&"/'; }
+ grep '\.java§' "§WORK/sources" | quote > "§WORK/java-sources"
+ CP="§WORK/classes"
+ if grep -q '\.kt§' "§WORK/sources"; then
+ find_jar() {
+ for version in §(ls -1 "§MAVEN/§1" 2>/dev/null | sort -r); do
+ jar=§(ls -1 "§MAVEN/§1/§version"/*.jar 2>/dev/null | grep -vE '(-sources|-javadoc)\.jar§' | head -n1)
+ if [ -n "§jar" ]; then echo "§jar"; return 0; fi
+ done
+ return 1
+ }
+ compiler=§(find_jar org/jetbrains/kotlin/kotlin-compiler-embeddable) || { echo "The Kotlin compiler is missing from §MAVEN." >&2; exit 4; }
+ stdlib=§(find_jar org/jetbrains/kotlin/kotlin-stdlib) || { echo "The Kotlin standard library is missing from §MAVEN." >&2; exit 4; }
+ boot="§compiler:§stdlib"
+ for artifact in org/jetbrains/kotlin/kotlin-script-runtime org/jetbrains/kotlin/kotlin-reflect \
+ org/jetbrains/kotlin/kotlin-daemon-embeddable org/jetbrains/kotlinx/kotlinx-coroutines-core-jvm \
+ org/jetbrains/annotations; do
+ jar=§(find_jar "§artifact") && boot="§boot:§jar"
+ done
+ quote < "§WORK/sources" > "§WORK/kotlin-sources"
+ echo ">> Compiling Kotlin"
+ "§JAVA" -Dkotlin.colors.enabled=false -cp "§boot" org.jetbrains.kotlin.cli.jvm.K2JVMCompiler \
+ -no-stdlib -no-reflect -classpath "§stdlib" -d "§WORK/classes" @"§WORK/kotlin-sources" || exit 1
+ CP="§CP:§stdlib"
+ fi
+ if [ -s "§WORK/java-sources" ]; then
+ echo ">> Compiling Java"
+ "§JAVAC" -cp "§CP" -d "§WORK/classes" @"§WORK/java-sources" || exit 1
+ fi
+ echo ">> Running §MAIN"
+ exec "§JAVA" -cp "§CP" "§MAIN" "§@"
+ """.trimIndent().replace('§', '$')
+ }
+}
diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/MainClassFinder.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/MainClassFinder.kt
new file mode 100644
index 00000000..3a365c3c
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/MainClassFinder.kt
@@ -0,0 +1,83 @@
+package com.appdevforall.jvm.plugin
+
+import java.io.File
+
+internal data class MainClass(val name: String, val source: File)
+
+internal object MainClassFinder {
+
+ private const val MAX_SOURCES = 2000
+
+ private val COMMENT = Regex("""/\*[\s\S]*?\*/|//[^\n]*""")
+ private val JAVA_PACKAGE = Regex("""^\s*package\s+([\w.]+)\s*;""", RegexOption.MULTILINE)
+ private val JAVA_TYPE = Regex("""\b(?:class|interface|enum|record)\s+(\w+)""")
+ private val JAVA_MAIN = Regex(
+ """\bstatic\s+(?:(?:public|final|synchronized|strictfp)\s+)*void\s+main\s*\(\s*(?:final\s+)?String\s*(?:\[\s*]|\.\.\.|\s\w+\s*\[\s*])"""
+ )
+ private val KOTLIN_PACKAGE = Regex("""^\s*package\s+([\w.]+)""", RegexOption.MULTILINE)
+ private val KOTLIN_JVM_NAME = Regex("""@file:JvmName\(\s*"(\w+)"\s*\)""")
+ private val KOTLIN_TOP_LEVEL_MAIN = Regex("""^(?:(?:public|suspend)\s+)*fun\s+main\s*\(""", RegexOption.MULTILINE)
+ private val KOTLIN_JVM_STATIC_MAIN = Regex("""@JvmStatic\s+(?:(?:public|suspend)\s+)*fun\s+main\s*\(""")
+ private val KOTLIN_OWNER = Regex("""\bobject\s+(\w+)|\bcompanion\s+object\b""")
+ private val KOTLIN_CLASS = Regex("""\bclass\s+(\w+)""")
+
+ fun isSource(file: File): Boolean = file.extension == "java" || file.extension == "kt"
+
+ fun isSkipped(dir: File): Boolean = dir.name == "build" || dir.name.startsWith(".")
+
+ fun find(root: File): List =
+ sources(root)
+ .flatMap { file -> mainClassNames(file.name, file.readText()).map { MainClass(it, file) } }
+ .distinctBy { it.name }
+ .sortedBy { it.name }
+
+ fun sources(root: File): List =
+ root.walkTopDown()
+ .onEnter { it == root || !isSkipped(it) }
+ .filter { it.isFile && isSource(it) }
+ .take(MAX_SOURCES)
+ .toList()
+
+ fun mainClassNames(fileName: String, text: String): List {
+ val code = COMMENT.replace(text, "")
+ return when {
+ fileName.endsWith(".java") -> javaMainClasses(code)
+ fileName.endsWith(".kt") -> kotlinMainClasses(fileName, code)
+ else -> emptyList()
+ }
+ }
+
+ private fun javaMainClasses(code: String): List {
+ val pkg = JAVA_PACKAGE.find(code)?.groupValues?.get(1)
+ return JAVA_MAIN.findAll(code)
+ .mapNotNull { main -> JAVA_TYPE.findAll(code.substring(0, main.range.first)).lastOrNull()?.groupValues?.get(1) }
+ .map { qualify(pkg, it) }
+ .toList()
+ }
+
+ private fun kotlinMainClasses(fileName: String, code: String): List {
+ val pkg = KOTLIN_PACKAGE.find(code)?.groupValues?.get(1)
+ val names = mutableListOf()
+ if (KOTLIN_TOP_LEVEL_MAIN.containsMatchIn(code)) {
+ names += KOTLIN_JVM_NAME.find(code)?.groupValues?.get(1) ?: facadeClassName(fileName)
+ }
+ KOTLIN_JVM_STATIC_MAIN.findAll(code).forEach { main ->
+ ownerOf(code.substring(0, main.range.first))?.let { names += it }
+ }
+ return names.map { qualify(pkg, it) }
+ }
+
+ private fun ownerOf(before: String): String? {
+ val owner = KOTLIN_OWNER.findAll(before).lastOrNull() ?: return null
+ return owner.groups[1]?.value
+ ?: KOTLIN_CLASS.findAll(before.substring(0, owner.range.first)).lastOrNull()?.groupValues?.get(1)
+ }
+
+ private fun facadeClassName(fileName: String): String {
+ val base = fileName.removeSuffix(".kt").map { if (it.isJavaIdentifierPart()) it else '_' }.joinToString("")
+ val start = if (base.firstOrNull()?.isJavaIdentifierStart() == true) base else "_$base"
+ return start.replaceFirstChar { it.uppercaseChar() } + "Kt"
+ }
+
+ private fun qualify(pkg: String?, name: String): String = if (pkg.isNullOrEmpty()) name else "$pkg.$name"
+}
diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/ProgramArguments.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/ProgramArguments.kt
new file mode 100644
index 00000000..26a44d7d
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/ProgramArguments.kt
@@ -0,0 +1,44 @@
+package com.appdevforall.jvm.plugin
+
+internal object ProgramArguments {
+
+ fun parse(input: String): List {
+ val args = mutableListOf()
+ val current = StringBuilder()
+ var inArgument = false
+ var quote: Char? = null
+ var i = 0
+ while (i < input.length) {
+ val c = input[i]
+ when {
+ quote == '\'' -> if (c == '\'') quote = null else current.append(c)
+ quote == '"' -> when {
+ c == '"' -> quote = null
+ c == '\\' && i + 1 < input.length && input[i + 1] in "\"\\" -> current.append(input[++i])
+ else -> current.append(c)
+ }
+ c.isWhitespace() -> if (inArgument) {
+ args += current.toString()
+ current.clear()
+ inArgument = false
+ }
+ c == '\'' || c == '"' -> {
+ quote = c
+ inArgument = true
+ }
+ c == '\\' && i + 1 < input.length -> {
+ current.append(input[++i])
+ inArgument = true
+ }
+ else -> {
+ current.append(c)
+ inArgument = true
+ }
+ }
+ i++
+ }
+ require(quote == null) { "Close the $quote quote in the program arguments." }
+ if (inArgument) args += current.toString()
+ return args
+ }
+}
diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationDialog.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationDialog.kt
new file mode 100644
index 00000000..e77a4ae1
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationDialog.kt
@@ -0,0 +1,112 @@
+package com.appdevforall.jvm.plugin
+
+import android.app.Activity
+import android.content.DialogInterface
+import android.text.InputType
+import android.util.TypedValue
+import android.view.View
+import android.widget.LinearLayout
+import android.widget.RadioGroup
+import android.widget.ScrollView
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.radiobutton.MaterialRadioButton
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
+import com.google.android.material.textview.MaterialTextView
+import com.itsaky.androidide.plugins.base.PluginWindows
+
+internal object RunConfigurationDialog {
+
+ private const val TITLE = "Run configuration"
+
+ fun show(
+ activity: Activity,
+ mainClasses: List,
+ selected: RunConfiguration,
+ onHelp: (View) -> Unit,
+ onSave: (RunConfiguration) -> Unit,
+ ) {
+ if (mainClasses.isEmpty()) {
+ PluginWindows.showDialog(
+ MaterialAlertDialogBuilder(activity)
+ .setTitle(TITLE)
+ .setMessage(
+ "No main class found. Add a Java class with public static void main(String[] args), " +
+ "or a Kotlin file with a top-level fun main()."
+ )
+ .setPositiveButton(android.R.string.ok, null)
+ .create()
+ )
+ return
+ }
+
+ val classes = RadioGroup(activity)
+ mainClasses.forEachIndexed { index, main ->
+ classes.addView(MaterialRadioButton(activity).apply {
+ id = index + 1
+ text = main.name
+ })
+ }
+ classes.check(mainClasses.indexOfFirst { it.name == selected.mainClass }.coerceAtLeast(0) + 1)
+
+ val argumentsLayout = TextInputLayout(activity).apply {
+ hint = "Program arguments"
+ helperText = "Separate arguments with spaces. Quote an argument that contains spaces."
+ setOnLongClickListener { view ->
+ onHelp(view)
+ true
+ }
+ }
+ val arguments = TextInputEditText(argumentsLayout.context).apply {
+ setText(selected.arguments)
+ inputType = InputType.TYPE_CLASS_TEXT or
+ InputType.TYPE_TEXT_FLAG_MULTI_LINE or
+ InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
+ }
+ argumentsLayout.addView(arguments)
+
+ val padding = dp(activity, 24)
+ val content = LinearLayout(activity).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(padding, dp(activity, 8), padding, 0)
+ addView(MaterialTextView(activity).apply {
+ text = "Main class"
+ setTextAppearance(android.R.style.TextAppearance_Material_Subhead)
+ setOnLongClickListener { view ->
+ onHelp(view)
+ true
+ }
+ })
+ addView(classes)
+ addView(
+ argumentsLayout,
+ LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
+ .apply { topMargin = dp(activity, 16) },
+ )
+ }
+
+ val dialog = MaterialAlertDialogBuilder(activity)
+ .setTitle(TITLE)
+ .setView(ScrollView(activity).apply { addView(content) })
+ .setPositiveButton("Save", null)
+ .setNegativeButton(android.R.string.cancel, null)
+ .create()
+ dialog.setOnShowListener {
+ dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener {
+ val text = arguments.text?.toString().orEmpty()
+ try {
+ ProgramArguments.parse(text)
+ } catch (e: IllegalArgumentException) {
+ argumentsLayout.error = e.message
+ return@setOnClickListener
+ }
+ onSave(RunConfiguration(mainClasses[classes.checkedRadioButtonId - 1].name, text))
+ dialog.dismiss()
+ }
+ }
+ PluginWindows.showDialog(dialog)
+ }
+
+ private fun dp(activity: Activity, value: Int): Int =
+ TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), activity.resources.displayMetrics).toInt()
+}
diff --git a/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationStore.kt b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationStore.kt
new file mode 100644
index 00000000..9eb016d9
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/kotlin/com/appdevforall/jvm/plugin/RunConfigurationStore.kt
@@ -0,0 +1,36 @@
+package com.appdevforall.jvm.plugin
+
+import org.json.JSONObject
+import java.io.File
+
+internal data class RunConfiguration(val mainClass: String, val arguments: String)
+
+internal class RunConfigurationStore(private val file: File) {
+
+ @Synchronized
+ fun get(projectRoot: File): RunConfiguration? {
+ val entry = read().optJSONObject(projectRoot.absolutePath) ?: return null
+ return RunConfiguration(entry.getString(KEY_MAIN_CLASS), entry.getString(KEY_ARGUMENTS))
+ }
+
+ @Synchronized
+ fun put(projectRoot: File, configuration: RunConfiguration) {
+ val all = read().put(
+ projectRoot.absolutePath,
+ JSONObject()
+ .put(KEY_MAIN_CLASS, configuration.mainClass)
+ .put(KEY_ARGUMENTS, configuration.arguments),
+ )
+ file.parentFile?.mkdirs()
+ val staged = File(file.parentFile, "${file.name}.tmp")
+ staged.writeText(all.toString())
+ check(staged.renameTo(file)) { "Could not save the run configuration to $file" }
+ }
+
+ private fun read(): JSONObject = if (file.isFile) JSONObject(file.readText()) else JSONObject()
+
+ private companion object {
+ const val KEY_MAIN_CLASS = "mainClass"
+ const val KEY_ARGUMENTS = "arguments"
+ }
+}
diff --git a/plugins/JVM-Tools/src/main/res/drawable/ic_run_configuration.xml b/plugins/JVM-Tools/src/main/res/drawable/ic_run_configuration.xml
new file mode 100644
index 00000000..7349d835
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/res/drawable/ic_run_configuration.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/plugins/JVM-Tools/src/main/res/drawable/ic_run_jvm.xml b/plugins/JVM-Tools/src/main/res/drawable/ic_run_jvm.xml
new file mode 100644
index 00000000..88f62917
--- /dev/null
+++ b/plugins/JVM-Tools/src/main/res/drawable/ic_run_jvm.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/MainClassFinderTest.kt b/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/MainClassFinderTest.kt
new file mode 100644
index 00000000..156136bd
--- /dev/null
+++ b/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/MainClassFinderTest.kt
@@ -0,0 +1,90 @@
+package com.appdevforall.jvm.plugin
+
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class MainClassFinderTest {
+
+ @get:Rule
+ val folder = TemporaryFolder()
+
+ @Test
+ fun `java main is named after the class that declares it, with its package`() {
+ val source = """
+ package com.example.app;
+
+ class Helper {}
+
+ public final class Launcher {
+ public static void main(String[] args) {}
+ }
+ """.trimIndent()
+
+ assertEquals(listOf("com.example.app.Launcher"), MainClassFinder.mainClassNames("Launcher.java", source))
+ }
+
+ @Test
+ fun `java main accepts varargs, c-style arrays and modifiers in any order`() {
+ assertEquals(listOf("A"), MainClassFinder.mainClassNames("A.java", "class A { static public void main(String... a) {} }"))
+ assertEquals(listOf("B"), MainClassFinder.mainClassNames("B.java", "class B { public static void main(String a[]) {} }"))
+ }
+
+ @Test
+ fun `java without a runnable main is not listed`() {
+ val source = """
+ class A {
+ // public static void main(String[] args) {}
+ /* public static void main(String[] args) {} */
+ public void main(String[] args) {}
+ static void main(int x) {}
+ }
+ """.trimIndent()
+
+ assertEquals(emptyList(), MainClassFinder.mainClassNames("A.java", source))
+ }
+
+ @Test
+ fun `kotlin top-level main runs as the file facade`() {
+ assertEquals(listOf("tools.MainKt"), MainClassFinder.mainClassNames("main.kt", "package tools\n\nfun main() {}\n"))
+ assertEquals(listOf("My_appKt"), MainClassFinder.mainClassNames("my-app.kt", "suspend fun main(args: Array) {}\n"))
+ assertEquals(listOf("Launcher"), MainClassFinder.mainClassNames("App.kt", "@file:JvmName(\"Launcher\")\nfun main() {}\n"))
+ }
+
+ @Test
+ fun `kotlin jvmstatic main runs in its object or in the class of its companion`() {
+ val source = """
+ object Tool {
+ @JvmStatic fun main(args: Array) {}
+ }
+
+ class App {
+ companion object {
+ @JvmStatic
+ fun main(args: Array) {}
+ }
+ }
+ """.trimIndent()
+
+ assertEquals(listOf("Tool", "App"), MainClassFinder.mainClassNames("Both.kt", source))
+ }
+
+ @Test
+ fun `kotlin main inside a class without JvmStatic is not listed`() {
+ assertEquals(emptyList(), MainClassFinder.mainClassNames("A.kt", "class A {\n fun main() {}\n}\n"))
+ }
+
+ @Test
+ fun `find walks the project, skipping build and hidden folders`() {
+ folder.newFolder("src", "tools")
+ folder.newFile("src/Main.java").writeText("public class Main { public static void main(String[] a) {} }")
+ folder.newFile("src/tools/Echo.kt").writeText("package tools\nfun main() {}\n")
+ folder.newFolder("build")
+ folder.newFile("build/Old.java").writeText("class Old { public static void main(String[] a) {} }")
+ folder.newFolder(".cg")
+ folder.newFile(".cg/Hidden.kt").writeText("fun main() {}\n")
+
+ assertEquals(listOf("Main", "tools.EchoKt"), MainClassFinder.find(folder.root).map { it.name })
+ }
+}
diff --git a/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/ProgramArgumentsTest.kt b/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/ProgramArgumentsTest.kt
new file mode 100644
index 00000000..813efe0b
--- /dev/null
+++ b/plugins/JVM-Tools/src/test/kotlin/com/appdevforall/jvm/plugin/ProgramArgumentsTest.kt
@@ -0,0 +1,30 @@
+package com.appdevforall.jvm.plugin
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+
+class ProgramArgumentsTest {
+
+ @Test
+ fun `splits on whitespace`() {
+ assertEquals(listOf("a", "b", "c"), ProgramArguments.parse(" a b\t\nc "))
+ assertEquals(emptyList(), ProgramArguments.parse(" "))
+ }
+
+ @Test
+ fun `quotes keep spaces and can hold the other quote`() {
+ assertEquals(listOf("--name", "Ada Lovelace", "it's"), ProgramArguments.parse("--name \"Ada Lovelace\" \"it's\""))
+ assertEquals(listOf("say \"hi\"", "a\\b"), ProgramArguments.parse("'say \"hi\"' 'a\\b'"))
+ }
+
+ @Test
+ fun `backslash escapes outside single quotes`() {
+ assertEquals(listOf("a b", "\"x\"", ""), ProgramArguments.parse("a\\ b \"\\\"x\\\"\" \"\""))
+ }
+
+ @Test
+ fun `an unclosed quote is rejected`() {
+ assertThrows(IllegalArgumentException::class.java) { ProgramArguments.parse("\"Ada") }
+ }
+}