diff --git a/app/src/main/java/com/itsaky/androidide/actions/BaseBuildAction.kt b/app/src/main/java/com/itsaky/androidide/actions/BaseBuildAction.kt
index 4edd1bd7f5..2c34b16e90 100644
--- a/app/src/main/java/com/itsaky/androidide/actions/BaseBuildAction.kt
+++ b/app/src/main/java/com/itsaky/androidide/actions/BaseBuildAction.kt
@@ -49,9 +49,9 @@ abstract class BaseBuildAction : EditorActivityAction() {
/**
* Refuses a tap while an internal build (Quick Build's proxy app build) holds the one Gradle
- * slot, and flashes why. [prepare] leaves these actions enabled in that window, and starting a
- * second build would throw BuildInProgressException deep in the service and surface as a raw
- * error. Reads the raw [BuildService.isBuildInProgress], because this guards the slot rather
+ * slot, and flashes why. [prepare] leaves these actions enabled in that window, and the service
+ * would refuse a second build silently, so the tap would seem to do nothing. Reads the raw
+ * [BuildService.isBuildInProgress], because this guards the slot rather
* than presenting state.
*
* @return true when the tap was refused and the caller must start nothing.
diff --git a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt
index f0680aee59..94c61819cd 100644
--- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt
+++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt
@@ -22,6 +22,7 @@ import com.itsaky.androidide.plugins.PluginLogger
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
import com.itsaky.androidide.plugins.manager.core.PluginManager
import com.itsaky.androidide.plugins.manager.services.IdeLogServiceImpl
+import com.itsaky.androidide.plugins.manager.services.IdeTerminalServiceImpl
import com.itsaky.androidide.preferences.internal.DevOpsPreferences
import com.itsaky.androidide.preferences.internal.GeneralPreferences
import com.itsaky.androidide.resources.localization.LocaleProvider
@@ -393,6 +394,7 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader {
setupBuildServiceProviders()
setupProjectManipulationProviders()
IdeLogServiceImpl.getInstance().setLogReader(LogsProvider::read)
+ IdeTerminalServiceImpl.setSessionLauncher(PluginTerminalLauncher { application.foregroundActivity })
logger.info("Plugin services configured successfully")
}
}
diff --git a/app/src/main/java/com/itsaky/androidide/app/PluginTerminalLauncher.kt b/app/src/main/java/com/itsaky/androidide/app/PluginTerminalLauncher.kt
new file mode 100644
index 0000000000..ae0df0357a
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/app/PluginTerminalLauncher.kt
@@ -0,0 +1,76 @@
+package com.itsaky.androidide.app
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Handler
+import android.os.Looper
+import com.itsaky.androidide.activities.TerminalActivity
+import com.itsaky.androidide.plugins.manager.services.TerminalSessionLauncher
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+import com.itsaky.androidide.terminal.TerminalCommandRequests
+import com.itsaky.androidide.utils.applyMultiWindowFlags
+import org.slf4j.LoggerFactory
+import java.io.File
+
+/**
+ * Opens a plugin's command in a new Terminal session, through the same activity the Terminal
+ * sidebar action opens.
+ */
+internal class PluginTerminalLauncher(
+ private val foregroundActivity: () -> Activity?,
+) : TerminalSessionLauncher {
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ override fun launch(
+ command: String,
+ workingDirectory: File?,
+ sessionName: String,
+ onResult: (TerminalCommandResult) -> Unit,
+ ): () -> Unit {
+ // Android blocks activity starts from the background, so a plugin can only open the
+ // Terminal while the IDE is on screen.
+ val activity = foregroundActivity()
+ if (activity == null) {
+ onResult(TerminalCommandResult.NotStarted("Code On the Go is not in the foreground"))
+ return {}
+ }
+
+ val requestId =
+ TerminalCommandRequests.enqueue(
+ command = command,
+ workingDirectory = workingDirectory?.absolutePath,
+ sessionName = sessionName,
+ onExit = { exitCode, transcript -> onResult(TerminalCommandResult.Completed(exitCode, transcript)) },
+ onNotStarted = { reason -> onResult(TerminalCommandResult.NotStarted(reason)) },
+ )
+ val intent =
+ Intent(activity, TerminalActivity::class.java)
+ .putExtra(TerminalCommandRequests.EXTRA_COMMAND_REQUEST_ID, requestId)
+ .applyMultiWindowFlags(activity)
+ try {
+ activity.startActivity(intent)
+ } catch (e: Exception) {
+ logger.error("Failed to open the Terminal for a plugin command", e)
+ if (TerminalCommandRequests.withdraw(requestId)) {
+ onResult(TerminalCommandResult.NotStarted("The Terminal could not be opened: ${e.message}"))
+ }
+ return {}
+ }
+
+ // The activity can fail to start or finish before its service connects; without this the
+ // plugin would wait forever for a session that never comes.
+ mainHandler.postDelayed({
+ if (TerminalCommandRequests.withdraw(requestId)) {
+ onResult(TerminalCommandResult.NotStarted("The Terminal did not open"))
+ }
+ }, OPEN_TIMEOUT_MS)
+
+ return { TerminalCommandRequests.cancel(requestId) }
+ }
+
+ private companion object {
+ private val logger = LoggerFactory.getLogger(PluginTerminalLauncher::class.java)
+
+ const val OPEN_TIMEOUT_MS = 15_000L
+ }
+}
diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildInProgressException.java b/app/src/main/java/com/itsaky/androidide/services/builder/BuildInProgressException.java
deleted file mode 100644
index ea5a73dff9..0000000000
--- a/app/src/main/java/com/itsaky/androidide/services/builder/BuildInProgressException.java
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * This file is part of AndroidIDE.
- *
- * AndroidIDE is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * AndroidIDE is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with AndroidIDE. If not, see .
- */
-
-package com.itsaky.androidide.services.builder;
-
-/**
- * Thrown when a build is requested but another build is already in progress.
- *
- * @author Akash Yadav
- */
-public class BuildInProgressException extends IllegalStateException {
-
- public BuildInProgressException() {
- this("A build is already running!");
- }
-
- public BuildInProgressException(final String s) {
- super(s);
- }
-}
diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt
index fa0ab32ba1..9cadc39e1a 100644
--- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt
+++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt
@@ -67,6 +67,7 @@ import com.itsaky.androidide.tooling.api.messages.result.BuildResult
import com.itsaky.androidide.tooling.api.messages.result.GradleWrapperCheckResult
import com.itsaky.androidide.tooling.api.messages.result.InitializeResult
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult
+import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.BUILD_IN_PROGRESS
import com.itsaky.androidide.tooling.api.models.ToolingServerMetadata
import com.itsaky.androidide.tooling.events.ProgressEvent
import com.itsaky.androidide.utils.Environment
@@ -94,6 +95,7 @@ import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionException
import java.util.concurrent.TimeoutException
+import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.cancellation.CancellationException
@@ -111,11 +113,11 @@ class GradleBuildService :
private var mBinder: GradleServiceBinder? = null
private var isToolingServerStarted = false
- // Volatile: written on the Tooling API's CompletableFuture pool, read cross-thread
- // by Quick Build's slot pre-check.
- @Volatile
- override var isBuildInProgress = false
- private set
+ // Atomic: two callers may race for the slot, and Quick Build's pre-check reads it cross-thread.
+ private val buildSlot = AtomicBoolean(false)
+
+ override val isBuildInProgress: Boolean
+ get() = buildSlot.get()
/**
* Gradle output captured while the editor's listener is suppressed, oldest line first.
@@ -237,8 +239,7 @@ class GradleBuildService :
/**
* The RPC future of the build holding the slot, failed by [onServerExited]: the RPC layer never
* completes a request whose server process died, and only that completion clears
- * [isBuildInProgress]. Never nulled - completing a finished future is a no-op, and a clear in
- * [markBuildAsFinished] would also run for a request rejected while another build still ran.
+ * [isBuildInProgress]. Never nulled - completing a finished future is a no-op.
*/
@Volatile
private var pendingBuild: CompletableFuture<*>? = null
@@ -817,7 +818,7 @@ class GradleBuildService :
checkServerStarted()
Objects.requireNonNull(params)
return try {
- performBuildTasks(server!!.initialize(params))
+ performBuildTasks(refused = InitializeResult.Failure(BUILD_IN_PROGRESS)) { server!!.initialize(params) }
} catch (_: ScanPluginMissingException) {
log.info("Retrying initialization without --scan option...")
initializeProject(params)
@@ -836,7 +837,10 @@ class GradleBuildService :
override fun executeTasks(message: TaskExecutionMessage): CompletableFuture {
checkServerStarted()
- val future = performBuildTasks(server!!.executeTasks(message))
+ val future =
+ performBuildTasks(refused = TaskExecutionResult(false, BUILD_IN_PROGRESS)) {
+ server!!.executeTasks(message)
+ }
return future.handle { result, exception ->
if (exception != null) {
@@ -856,9 +860,29 @@ class GradleBuildService :
return server!!.cancelCurrentBuild()
}
- private fun performBuildTasks(future: CompletableFuture): CompletableFuture {
+ private fun performBuildTasks(
+ refused: T,
+ dispatch: () -> CompletableFuture,
+ ): CompletableFuture {
+ // Claimed before the request is sent and released only by the build that claimed it, so a
+ // refused request can neither reach the tooling server nor free another build's slot.
+ if (!buildSlot.compareAndSet(false, true)) {
+ logBuildInProgress()
+ // Not null: markBuildAsFinished turns a failed build into null, and callers must tell a
+ // build that never started from one that failed.
+ return CompletableFuture.completedFuture(refused)
+ }
+ val future =
+ try {
+ dispatch()
+ } catch (e: Throwable) {
+ buildSlot.set(false)
+ throw e
+ }
+ pendingBuild = future
+
return CompletableFuture
- .runAsync { onPrepareBuildRequest(future) }
+ .runAsync { ensureTmpdir() }
.handleAsync { _, _ ->
try {
return@handleAsync future.get()
@@ -917,17 +941,6 @@ class GradleBuildService :
return false
}
- private fun onPrepareBuildRequest(future: CompletableFuture<*>) {
- checkServerStarted()
- ensureTmpdir()
- if (isBuildInProgress) {
- logBuildInProgress()
- throw BuildInProgressException()
- }
- isBuildInProgress = true
- pendingBuild = future
- }
-
@Throws(ToolingServerNotStartedException::class)
private fun checkServerStarted() {
if (!isToolingServerStarted()) {
@@ -948,7 +961,7 @@ class GradleBuildService :
result: T,
throwable: Throwable?,
): T {
- isBuildInProgress = false
+ buildSlot.set(false)
return result
}
diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceServerExitTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceServerExitTest.kt
index 5ada89a4c1..21138bf943 100644
--- a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceServerExitTest.kt
+++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceServerExitTest.kt
@@ -49,8 +49,7 @@ class GradleBuildServiceServerExitTest {
service.onListenerStarted(server, ByteArrayInputStream(ByteArray(0)))
val build = service.executeTasks(listOf(":app:assembleDebug"))
- // The slot is taken on the build's own future chain, not on the caller's thread.
- awaitUntil { service.isBuildInProgress }
+ // The slot is taken on the caller's thread, before the request is sent.
assertThat(service.isBuildInProgress).isTrue()
service.onServerExited(137)
diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceSlotTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceSlotTest.kt
new file mode 100644
index 0000000000..9b92009b81
--- /dev/null
+++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceSlotTest.kt
@@ -0,0 +1,81 @@
+package com.itsaky.androidide.services.builder
+
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.tooling.api.IToolingApiServer
+import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult
+import com.itsaky.androidide.utils.Environment
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import java.io.ByteArrayInputStream
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.TimeUnit
+
+/**
+ * The Gradle build slot when a second build is requested while one runs.
+ *
+ * The defects this pins: the slot was checked and taken on a pool thread after the request had
+ * already been sent, so two callers could both reach the tooling server; and a refused request
+ * still cleared the slot when it completed, while the first build was running.
+ */
+@RunWith(RobolectricTestRunner::class)
+class GradleBuildServiceSlotTest {
+ @get:Rule
+ val tmp = TemporaryFolder()
+
+ private lateinit var service: GradleBuildService
+ private val rpc = CompletableFuture()
+
+ // A second request that reaches the server is refused there, as ToolingApiServerImpl does.
+ private val server =
+ mockk {
+ every { executeTasks(any()) } returnsMany
+ listOf(rpc, CompletableFuture.failedFuture(IllegalStateException("Build is already in progress")))
+ }
+
+ @Before
+ fun setUp() {
+ Environment.TMP_DIR = tmp.newFolder("tmp")
+ service = Robolectric.buildService(GradleBuildService::class.java).get()
+ service.onListenerStarted(server, ByteArrayInputStream(ByteArray(0)))
+ }
+
+ @Test
+ fun `a second build is refused without reaching the tooling server`() {
+ service.executeTasks(listOf(":app:assembleDebug"))
+ val second = service.executeTasks(listOf(":app:test"))
+
+ // Distinct from a failed build, which completes as null.
+ assertThat(second.get(5, TimeUnit.SECONDS))
+ .isEqualTo(TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_IN_PROGRESS))
+ verify(exactly = 1) { server.executeTasks(any()) }
+ }
+
+ @Test
+ fun `a refused build leaves the running build's slot taken`() {
+ val first = service.executeTasks(listOf(":app:assembleDebug"))
+ service.executeTasks(listOf(":app:test")).get(5, TimeUnit.SECONDS)
+
+ assertThat(service.isBuildInProgress).isTrue()
+
+ rpc.complete(TaskExecutionResult(isSuccessful = true, failure = null))
+ first.get(5, TimeUnit.SECONDS)
+ assertThat(service.isBuildInProgress).isFalse()
+ }
+
+ @Test
+ fun `a request that fails to send frees the slot`() {
+ every { server.executeTasks(any()) } throws IllegalStateException("closed")
+
+ runCatching { service.executeTasks(listOf(":app:assembleDebug")) }
+
+ assertThat(service.isBuildInProgress).isFalse()
+ }
+}
diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md
index 7a9f73b90d..00bf717838 100644
--- a/docs/PLUGIN_API_CHANGELOG.md
+++ b/docs/PLUGIN_API_CHANGELOG.md
@@ -36,6 +36,35 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed]
= diffed from `plugin-api/src` history (predates the dump; symbol-accurate).
### 26.41 — unreleased
+- **added — Terminal readiness and visible terminal commands** _(ADFA-6373)_ **[verified]**
+ `IdeTerminalService.isTerminalReady()` reports whether the terminal environment is installed
+ and bash runs; it needs no permission. `runInTerminal(command, workingDirectory)` opens a new
+ session in the visible Terminal, runs the command with bash and suspends until it exits,
+ returning `TerminalCommandResult.Completed(exitCode, output)` with the session transcript, or
+ `NotStarted(reason)` (environment missing, IDE not in the foreground). The session stays open
+ so the user sees what ran; cancelling the caller kills the command. Needs `system.commands`;
+ the working directory must lie inside the project. Floor `plugin.min_ide_version` at `26.41`:
+ an older IDE has no `IdeTerminalService` class, so referencing it fails to load.
+- **added — Run Gradle tasks with arguments, get a structured result, cancel** _(ADFA-6373)_ **[verified]**
+ `IdeBuildService.executeTasks(tasks: List, arguments: List)` runs on the
+ IDE's tooling server, so `--tests`, `-P` and `--info` work and the output reaches the Build
+ Output pane (read it with `getBuildOutput()`). It completes with a `GradleTaskResult`:
+ `Success`, `Failed(reason)`, `Refused(reason)` when the build never started (another build
+ running, tooling server down), or `Cancelled`. `IdeBuildService.cancelBuild()` cancels the
+ running build, whoever started it. `executeTasks(vararg String)` is unchanged. Floor
+ `plugin.min_ide_version` at `26.41`: an older IDE has neither method.
+- **breaking — `CommandSpec.GradleTask` runs on the tooling server** _(ADFA-6373)_
+ It used to start `./gradlew` as a separate process: a second Gradle daemon on the device,
+ with output that never reached the Build Output pane. It now runs like `executeTasks` above.
+ Output arrives in one batch of `StdOut` lines when the build ends instead of streaming; the
+ exit code is 0 on success and 1 on a failed build; a refused build fails with exit code -1
+ and the reason in `CommandResult.Failure.error`. No source change is needed. A plugin that
+ ran a Gradle task while another build was running now gets that refusal instead of a
+ second build.
+- **breaking — `CommandSpec.ShellCommand.workingDirectory` needs an open project** _(ADFA-6373)_
+ With no project open, a `workingDirectory` used to be accepted unchecked; `executeCommand` now
+ throws `SecurityException`, as it does for one outside the project. Pass null to run in the
+ default directory.
- **added — Plugin languages: tree-sitter highlighting and a language server** _(ADFA-4851)_ **[verified]**
A plugin implementing `LanguageExtension` returns `LanguageDefinition`s, each claiming file
extensions and optionally carrying a `TreeSitterGrammar` and a `LanguageServerDefinition`.
diff --git a/docs/plugin-api.md b/docs/plugin-api.md
index 9b1a83256e..2ee7f69918 100644
--- a/docs/plugin-api.md
+++ b/docs/plugin-api.md
@@ -11,7 +11,7 @@ The surface a plugin binds to is broader than one module. All of the following a
- **The `:plugin-api` module** — package `com.itsaky.androidide.plugins.*`:
- Core: `IPlugin` (lifecycle), `PluginContext`, `PluginLogger`, `ServiceRegistry`, `ResourceManager`.
- Extension interfaces plugins **implement**: `UIExtension`, `EditorExtension`, `EditorTabExtension`, `DocumentationExtension`, `BuildActionExtension`, `SnippetExtension`, `ProjectExtension`, `FileOpenExtension`, `SettingsExtension`, `LanguageExtension`.
- - IDE service interfaces plugins **call** (via `ServiceRegistry.get(X::class.java)`): `IdeProjectService`, `IdeEditorService`, `IdeFileService`, `IdeEnvironmentService`, `IdeArchiveService`, `IdeBuildService`, `IdeLogService`, `IdeUIService`, `IdeEditorTabService`, `IdeTooltipService`, `IdeThemeService`, `IdeFeatureFlagService`, `IdeCommandService`, `IdeTemplateService`, `IdeSnippetService`, `IdeSidebarService`.
+ - IDE service interfaces plugins **call** (via `ServiceRegistry.get(X::class.java)`): `IdeProjectService`, `IdeEditorService`, `IdeFileService`, `IdeEnvironmentService`, `IdeArchiveService`, `IdeBuildService`, `IdeLogService`, `IdeUIService`, `IdeEditorTabService`, `IdeTooltipService`, `IdeThemeService`, `IdeFeatureFlagService`, `IdeCommandService`, `IdeTerminalService`, `IdeTemplateService`, `IdeSnippetService`, `IdeSidebarService`.
- Cross-plugin service interfaces, where **one plugin implements what another calls** (via `SharedServices`): `LlmInferenceService` — implemented by ai-core, called by every AI plugin — together with the types nested in it that a *backend* plugin implements (`LlmBackend`, `HistoryCapableBackend`, `ToolCallingBackend`, `CancellableBackend`, `ConfigurableBackend`, `EmbeddingBackend`) and the value types either side constructs (`ChatMessage`, `LlmConfig`, `LlmResponse`, `SystemPromptRequest`, `ToolDefinition`, `ToolCallRequest`). Also `ToolSourceRegistry` — implemented by ai-core, called by any plugin contributing tools to the agent — with `ToolSource` and `ToolSpec`, which a *contributing* plugin implements, `ToolInvocation`, which ai-core constructs and passes to `ToolSource.invoke`, and `ToolOutcome`, which the source returns.
- Utility classes plugins **instantiate**: `KeystoreSecretStore` (AES/GCM over the Android Keystore, alias supplied by the caller); the AI prompt config engine in `ai.prompt` (`PromptTemplateEngine`, `PromptConfigLoader`, `PromptConfigStore`, `PromptConfigDocument`, `PromptConfigObject`, `AssetPromptConfigSource`), generic over a plugin's own config type through `PromptConfigParser` and `PromptConfigProvider`, which a plugin implements; and the settings-pane helpers in `ai.ui` (`SecretRevealController` with `RevealToggle`, `applyPaneStyling` with `PaneStyle`, `ButtonColors` and `FieldColors`). Host-side implementations rather than interfaces, so plugins share one copy in the process instead of compiling their own.
- Data classes plugins **construct** (e.g. `MenuItem`, `TabItem`, `EditorTabItem`, `NavigationItem`, `ToolbarAction`, `FabAction`, `PluginBuildAction`, `SnippetContribution`, `PluginTooltipEntry`, `PluginSettingsEntry`, `LanguageDefinition`, `TreeSitterGrammar`, `LanguageServerDefinition`).
diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api
index adaf15aa4f..9ac8d0a975 100644
--- a/plugin-api/api/plugin-api.api
+++ b/plugin-api/api/plugin-api.api
@@ -1610,6 +1610,64 @@ public abstract interface class com/itsaky/androidide/plugins/services/GradleSyn
public abstract fun onComplete (ZLjava/lang/String;)V
}
+public final class com/itsaky/androidide/plugins/services/GradleTaskInfo {
+ public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
+ public final fun component1 ()Ljava/lang/String;
+ public final fun component2 ()Ljava/lang/String;
+ public final fun component3 ()Ljava/lang/String;
+ public final fun component4 ()Ljava/lang/String;
+ public final fun component5 ()Ljava/lang/String;
+ public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/GradleTaskInfo;
+ public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/GradleTaskInfo;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/GradleTaskInfo;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getDescription ()Ljava/lang/String;
+ public final fun getGroup ()Ljava/lang/String;
+ public final fun getName ()Ljava/lang/String;
+ public final fun getPath ()Ljava/lang/String;
+ public final fun getProjectPath ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public abstract class com/itsaky/androidide/plugins/services/GradleTaskResult {
+}
+
+public final class com/itsaky/androidide/plugins/services/GradleTaskResult$Cancelled : com/itsaky/androidide/plugins/services/GradleTaskResult {
+ public static final field INSTANCE Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Cancelled;
+ public fun equals (Ljava/lang/Object;)Z
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/itsaky/androidide/plugins/services/GradleTaskResult$Failed : com/itsaky/androidide/plugins/services/GradleTaskResult {
+ public fun (Ljava/lang/String;)V
+ public final fun component1 ()Ljava/lang/String;
+ public final fun copy (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Failed;
+ public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Failed;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Failed;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getReason ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/itsaky/androidide/plugins/services/GradleTaskResult$Refused : com/itsaky/androidide/plugins/services/GradleTaskResult {
+ public fun (Ljava/lang/String;)V
+ public final fun component1 ()Ljava/lang/String;
+ public final fun copy (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Refused;
+ public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Refused;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Refused;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getReason ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/itsaky/androidide/plugins/services/GradleTaskResult$Success : com/itsaky/androidide/plugins/services/GradleTaskResult {
+ public static final field INSTANCE Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Success;
+ public fun equals (Ljava/lang/Object;)Z
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
public abstract interface class com/itsaky/androidide/plugins/services/IdeArchiveService {
public abstract fun extract (Ljava/io/InputStream;Lcom/itsaky/androidide/plugins/services/ArchiveFormat;Ljava/io/File;Lkotlin/jvm/functions/Function2;)Lcom/itsaky/androidide/plugins/services/ExtractResult;
}
@@ -1620,8 +1678,11 @@ public final class com/itsaky/androidide/plugins/services/IdeArchiveService$Defa
public abstract interface class com/itsaky/androidide/plugins/services/IdeBuildService {
public abstract fun addBuildStatusListener (Lcom/itsaky/androidide/plugins/services/BuildStatusListener;)V
+ public abstract fun cancelBuild ()Ljava/util/concurrent/CompletableFuture;
+ public abstract fun executeTasks (Ljava/util/List;Ljava/util/List;)Ljava/util/concurrent/CompletableFuture;
public abstract fun executeTasks ([Ljava/lang/String;)Ljava/util/concurrent/CompletableFuture;
public abstract fun getBuildOutput ()Ljava/lang/String;
+ public abstract fun getTasks ()Ljava/util/List;
public abstract fun isBuildInProgress ()Z
public abstract fun isToolingServerStarted ()Z
public abstract fun removeBuildStatusListener (Lcom/itsaky/androidide/plugins/services/BuildStatusListener;)V
@@ -1630,8 +1691,12 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeBuildS
}
public final class com/itsaky/androidide/plugins/services/IdeBuildService$DefaultImpls {
+ public static fun cancelBuild (Lcom/itsaky/androidide/plugins/services/IdeBuildService;)Ljava/util/concurrent/CompletableFuture;
+ public static fun executeTasks (Lcom/itsaky/androidide/plugins/services/IdeBuildService;Ljava/util/List;Ljava/util/List;)Ljava/util/concurrent/CompletableFuture;
public static fun executeTasks (Lcom/itsaky/androidide/plugins/services/IdeBuildService;[Ljava/lang/String;)Ljava/util/concurrent/CompletableFuture;
+ public static synthetic fun executeTasks$default (Lcom/itsaky/androidide/plugins/services/IdeBuildService;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Ljava/util/concurrent/CompletableFuture;
public static fun getBuildOutput (Lcom/itsaky/androidide/plugins/services/IdeBuildService;)Ljava/lang/String;
+ public static fun getTasks (Lcom/itsaky/androidide/plugins/services/IdeBuildService;)Ljava/util/List;
public static fun runApp (Lcom/itsaky/androidide/plugins/services/IdeBuildService;Lcom/itsaky/androidide/plugins/services/BuildAndLaunchCallback;)V
public static fun triggerGradleSync (Lcom/itsaky/androidide/plugins/services/IdeBuildService;Lcom/itsaky/androidide/plugins/services/GradleSyncCallback;)V
}
@@ -1783,6 +1848,15 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeTempla
public abstract fun unregisterTemplate (Ljava/lang/String;)Z
}
+public abstract interface class com/itsaky/androidide/plugins/services/IdeTerminalService {
+ public abstract fun isTerminalReady (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public abstract fun runInTerminal (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class com/itsaky/androidide/plugins/services/IdeTerminalService$DefaultImpls {
+ public static synthetic fun runInTerminal$default (Lcom/itsaky/androidide/plugins/services/IdeTerminalService;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+}
+
public abstract interface class com/itsaky/androidide/plugins/services/IdeThemeService {
public abstract fun addThemeChangeListener (Lcom/itsaky/androidide/plugins/services/ThemeChangeListener;)V
public abstract fun isDarkMode ()Z
@@ -2094,6 +2168,33 @@ public final class com/itsaky/androidide/plugins/services/SharedServices {
public final fun unregister (Ljava/lang/Class;)V
}
+public abstract class com/itsaky/androidide/plugins/services/TerminalCommandResult {
+}
+
+public final class com/itsaky/androidide/plugins/services/TerminalCommandResult$Completed : com/itsaky/androidide/plugins/services/TerminalCommandResult {
+ public fun (ILjava/lang/String;)V
+ public final fun component1 ()I
+ public final fun component2 ()Ljava/lang/String;
+ public final fun copy (ILjava/lang/String;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Completed;
+ public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Completed;ILjava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Completed;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getExitCode ()I
+ public final fun getOutput ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/itsaky/androidide/plugins/services/TerminalCommandResult$NotStarted : com/itsaky/androidide/plugins/services/TerminalCommandResult {
+ public fun (Ljava/lang/String;)V
+ public final fun component1 ()Ljava/lang/String;
+ public final fun copy (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$NotStarted;
+ public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$NotStarted;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$NotStarted;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getReason ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
public abstract interface class com/itsaky/androidide/plugins/services/ThemeChangeListener {
public abstract fun onThemeChanged (Z)V
}
diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt
index e4350a5978..2cdb185ff5 100644
--- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt
+++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt
@@ -3,90 +3,135 @@ package com.itsaky.androidide.plugins.extensions
import com.itsaky.androidide.plugins.IPlugin
interface BuildActionExtension : IPlugin {
- fun getBuildActions(): List
- fun toolbarActionsToHide(): Set = emptySet()
- fun onActionStarted(actionId: String) {}
- fun onActionCompleted(actionId: String, result: CommandResult) {}
+ fun getBuildActions(): List
+
+ fun toolbarActionsToHide(): Set = emptySet()
+
+ fun onActionStarted(actionId: String) {}
+
+ fun onActionCompleted(
+ actionId: String,
+ result: CommandResult,
+ ) {}
}
object ToolbarActionIds {
- const val QUICK_RUN = "ide.editor.build.quickRun"
- const val PROJECT_SYNC = "ide.editor.syncProject"
- const val DEBUG = "ide.editor.build.debug"
- const val RUN_TASKS = "ide.editor.build.runTasks"
- const val UNDO = "ide.editor.code.text.undo"
- const val REDO = "ide.editor.code.text.redo"
- const val SAVE = "ide.editor.files.saveAll"
- const val PREVIEW_LAYOUT = "ide.editor.previewLayout"
- const val FIND = "ide.editor.find"
- const val FIND_IN_FILE = "ide.editor.find.inFile"
- const val FIND_IN_PROJECT = "ide.editor.find.inProject"
- const val LAUNCH_APP = "ide.editor.launchInstalledApp"
- const val DISCONNECT_LOG_SENDERS = "ide.editor.service.logreceiver.disconnectSenders"
- const val GENERATE_XML = "ide.editor.generatexml"
-
- val ALL: Set = setOf(
- QUICK_RUN, PROJECT_SYNC, DEBUG, RUN_TASKS,
- UNDO, REDO, SAVE, PREVIEW_LAYOUT,
- FIND, FIND_IN_FILE, FIND_IN_PROJECT,
- LAUNCH_APP, DISCONNECT_LOG_SENDERS, GENERATE_XML
- )
-
- val BUILD_HIDEABLE: Set = setOf(
- QUICK_RUN, PROJECT_SYNC, DEBUG, RUN_TASKS, LAUNCH_APP
- )
+ const val QUICK_RUN = "ide.editor.build.quickRun"
+ const val PROJECT_SYNC = "ide.editor.syncProject"
+ const val DEBUG = "ide.editor.build.debug"
+ const val RUN_TASKS = "ide.editor.build.runTasks"
+ const val UNDO = "ide.editor.code.text.undo"
+ const val REDO = "ide.editor.code.text.redo"
+ const val SAVE = "ide.editor.files.saveAll"
+ const val PREVIEW_LAYOUT = "ide.editor.previewLayout"
+ const val FIND = "ide.editor.find"
+ const val FIND_IN_FILE = "ide.editor.find.inFile"
+ const val FIND_IN_PROJECT = "ide.editor.find.inProject"
+ const val LAUNCH_APP = "ide.editor.launchInstalledApp"
+ const val DISCONNECT_LOG_SENDERS = "ide.editor.service.logreceiver.disconnectSenders"
+ const val GENERATE_XML = "ide.editor.generatexml"
+
+ val ALL: Set =
+ setOf(
+ QUICK_RUN,
+ PROJECT_SYNC,
+ DEBUG,
+ RUN_TASKS,
+ UNDO,
+ REDO,
+ SAVE,
+ PREVIEW_LAYOUT,
+ FIND,
+ FIND_IN_FILE,
+ FIND_IN_PROJECT,
+ LAUNCH_APP,
+ DISCONNECT_LOG_SENDERS,
+ GENERATE_XML,
+ )
+
+ val BUILD_HIDEABLE: Set =
+ setOf(
+ QUICK_RUN,
+ PROJECT_SYNC,
+ DEBUG,
+ RUN_TASKS,
+ LAUNCH_APP,
+ )
}
data class PluginBuildAction(
- val id: String,
- val name: String,
- val description: String,
- val icon: Int? = null,
- val category: BuildActionCategory = BuildActionCategory.CUSTOM,
- val command: CommandSpec,
- val timeoutMs: Long = 600_000
+ val id: String,
+ val name: String,
+ val description: String,
+ val icon: Int? = null,
+ val category: BuildActionCategory = BuildActionCategory.CUSTOM,
+ val command: CommandSpec,
+ val timeoutMs: Long = 600_000,
)
sealed class CommandSpec {
- data class ShellCommand(
- val executable: String,
- val arguments: List = emptyList(),
- val workingDirectory: String? = null,
- val environment: Map = emptyMap()
- ) : CommandSpec()
-
- data class GradleTask(
- val taskPath: String,
- val arguments: List = emptyList()
- ) : CommandSpec()
+ /**
+ * Runs [executable] directly, without a shell. [workingDirectory] is absolute or relative to
+ * the project root, and must lie inside it; null means the project root. Giving one with no
+ * project open throws [SecurityException].
+ */
+ data class ShellCommand(
+ val executable: String,
+ val arguments: List = emptyList(),
+ val workingDirectory: String? = null,
+ val environment: Map = emptyMap(),
+ ) : CommandSpec()
+
+ /**
+ * Runs [taskPath] with Gradle [arguments] through the IDE's tooling server, as
+ * [com.itsaky.androidide.plugins.services.IdeBuildService.executeTasks] does; it never starts
+ * a second Gradle daemon. Output goes to the Build Output pane and arrives as one
+ * [CommandOutput.StdOut] per line once the build ends; it is not streamed. Exit code 0 means
+ * success, 1 a failed build. A refused build (another one running, tooling server down)
+ * fails with exit code -1 and the reason in [CommandResult.Failure.error].
+ *
+ * Before 26.41 this ran `./gradlew` as a separate process.
+ */
+ data class GradleTask(
+ val taskPath: String,
+ val arguments: List = emptyList(),
+ ) : CommandSpec()
}
sealed class CommandOutput {
- data class StdOut(val line: String) : CommandOutput()
- data class StdErr(val line: String) : CommandOutput()
- data class ExitCode(val code: Int) : CommandOutput()
+ data class StdOut(
+ val line: String,
+ ) : CommandOutput()
+
+ data class StdErr(
+ val line: String,
+ ) : CommandOutput()
+
+ data class ExitCode(
+ val code: Int,
+ ) : CommandOutput()
}
sealed class CommandResult {
- data class Success(
- val exitCode: Int,
- val stdout: String,
- val stderr: String,
- val durationMs: Long
- ) : CommandResult()
-
- data class Failure(
- val exitCode: Int,
- val stdout: String,
- val stderr: String,
- val error: String?,
- val durationMs: Long
- ) : CommandResult()
-
- data class Cancelled(
- val partialStdout: String,
- val partialStderr: String
- ) : CommandResult()
+ data class Success(
+ val exitCode: Int,
+ val stdout: String,
+ val stderr: String,
+ val durationMs: Long,
+ ) : CommandResult()
+
+ data class Failure(
+ val exitCode: Int,
+ val stdout: String,
+ val stderr: String,
+ val error: String?,
+ val durationMs: Long,
+ ) : CommandResult()
+
+ data class Cancelled(
+ val partialStdout: String,
+ val partialStderr: String,
+ ) : CommandResult()
}
-enum class BuildActionCategory { BUILD, TEST, DEPLOY, LINT, CUSTOM }
\ No newline at end of file
+enum class BuildActionCategory { BUILD, TEST, DEPLOY, LINT, CUSTOM }
diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt
index 82d08ddbc9..1dccadda27 100644
--- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt
+++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt
@@ -393,6 +393,35 @@ interface IdeBuildService {
*/
fun executeTasks(vararg tasks: String): CompletableFuture = CompletableFuture.completedFuture(false)
+ /**
+ * Runs [tasks] (e.g. ":app:testDebugUnitTest") with Gradle [arguments] (e.g. "--tests",
+ * "com.example.FooTest", "-Pkey=value", "--info") through the IDE's tooling server, the same
+ * Gradle daemon the IDE builds with. The output goes to the Build Output pane; read it
+ * afterwards with [getBuildOutput]. A task option such as `--tests` or `--rerun` applies to
+ * the last task in [tasks], as on a command line.
+ *
+ * Only one build runs at a time. If one is already running, or the tooling server has not
+ * started, the future completes with [GradleTaskResult.Refused] at once and no build starts.
+ *
+ * The default body is not a compatibility shim: this module sets no `-Xjvm-default`, so an
+ * IDE older than 26.41 has no such method and the call fails with `NoSuchMethodError`. Floor
+ * `plugin.min_ide_version` at 26.41 to use it.
+ */
+ fun executeTasks(
+ tasks: List,
+ arguments: List = emptyList(),
+ ): CompletableFuture =
+ CompletableFuture.completedFuture(GradleTaskResult.Refused("Running Gradle tasks is not supported"))
+
+ /**
+ * Asks Gradle to cancel the running build, whoever started it. The future completes with
+ * true if the request was accepted, false if no build is running or the tooling server is
+ * down. The cancelled run's own future then completes with [GradleTaskResult.Cancelled].
+ *
+ * Floor `plugin.min_ide_version` at 26.41 to use it (see [executeTasks]).
+ */
+ fun cancelBuild(): CompletableFuture = CompletableFuture.completedFuture(false)
+
/**
* Builds and runs the app on the connected device.
* @param callback The callback to be invoked when the operation completes
@@ -414,6 +443,54 @@ interface IdeBuildService {
* @return The build output as a string, or null if no build output is available
*/
fun getBuildOutput(): String? = null
+
+ /**
+ * Lists the Gradle tasks of the open project, root project and modules alike, as Gradle
+ * reported them at the last sync. Empty when no project is open or it has not synced. A task
+ * added to a build script since then appears only after the next sync.
+ *
+ * Floor `plugin.min_ide_version` at 26.41 to use it (see [executeTasks]).
+ */
+ fun getTasks(): List = emptyList()
+}
+
+/**
+ * A Gradle task of the open project, as listed by [IdeBuildService.getTasks].
+ *
+ * @param path The task path to run it by, e.g. ":app:testDebugUnitTest".
+ * @param projectPath The path of the project that owns the task, e.g. ":app" or ":" for the root.
+ * @param group The task's group, e.g. "verification"; null for a task with no group.
+ */
+data class GradleTaskInfo(
+ val path: String,
+ val name: String,
+ val projectPath: String,
+ val group: String?,
+ val description: String?,
+)
+
+/**
+ * Outcome of [IdeBuildService.executeTasks] with Gradle arguments.
+ */
+sealed class GradleTaskResult {
+ /** The build ran and succeeded. */
+ data object Success : GradleTaskResult()
+
+ /**
+ * The build ran and failed. [reason] names the failure (e.g. "BUILD_FAILED"); the log is in
+ * [IdeBuildService.getBuildOutput].
+ */
+ data class Failed(
+ val reason: String,
+ ) : GradleTaskResult()
+
+ /** The build never started. [reason] says why, e.g. another build is in progress. */
+ data class Refused(
+ val reason: String,
+ ) : GradleTaskResult()
+
+ /** The build started and was cancelled, by [IdeBuildService.cancelBuild] or by the user. */
+ data object Cancelled : GradleTaskResult()
}
/**
diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeTerminalService.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeTerminalService.kt
new file mode 100644
index 0000000000..c216a3f4be
--- /dev/null
+++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeTerminalService.kt
@@ -0,0 +1,51 @@
+package com.itsaky.androidide.plugins.services
+
+/**
+ * Service interface for the IDE's terminal environment (bash and the packages installed with it).
+ *
+ * Use [IdeCommandService] to run a command headless; use [runInTerminal] when the user should see
+ * what ran. Available since 26.41.
+ */
+interface IdeTerminalService {
+ /**
+ * True when the terminal environment is installed and bash starts and exits cleanly. Starts a
+ * process, so call it off the main thread. Needs no permission.
+ */
+ suspend fun isTerminalReady(): Boolean
+
+ /**
+ * Opens a new session in the visible Terminal, runs [command] there with bash, and suspends
+ * until it exits. The session stays open afterwards so the user can read what ran.
+ *
+ * [workingDirectory] is absolute or relative to the project root, and must lie inside it;
+ * null means the project root. Cancelling the calling coroutine kills the command.
+ *
+ * Requires the SYSTEM_COMMANDS permission.
+ *
+ * @throws SecurityException if the plugin lacks SYSTEM_COMMANDS, or [workingDirectory] is
+ * outside the project root or given with no project open.
+ */
+ suspend fun runInTerminal(
+ command: String,
+ workingDirectory: String? = null,
+ ): TerminalCommandResult
+}
+
+/**
+ * Outcome of [IdeTerminalService.runInTerminal].
+ */
+sealed class TerminalCommandResult {
+ /**
+ * The command ran and exited with [exitCode]. [output] is the session transcript: the echoed
+ * command line followed by stdout and stderr interleaved as the terminal showed them.
+ */
+ data class Completed(
+ val exitCode: Int,
+ val output: String,
+ ) : TerminalCommandResult()
+
+ /** The command never ran. [reason] says why, e.g. the terminal environment is not installed. */
+ data class NotStarted(
+ val reason: String,
+ ) : TerminalCommandResult()
+}
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt
index 46264abdf4..9d1f1d89f7 100644
--- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt
@@ -56,6 +56,7 @@ import com.itsaky.androidide.plugins.manager.services.IdeProjectServiceImpl
import com.itsaky.androidide.plugins.manager.services.IdeSidebarServiceImpl
import com.itsaky.androidide.plugins.manager.services.IdeSnippetServiceImpl
import com.itsaky.androidide.plugins.manager.services.IdeTemplateServiceImpl
+import com.itsaky.androidide.plugins.manager.services.IdeTerminalServiceImpl
import com.itsaky.androidide.plugins.manager.services.IdeThemeServiceImpl
import com.itsaky.androidide.plugins.manager.services.IdeTooltipServiceImpl
import com.itsaky.androidide.plugins.manager.services.IdeUIServiceImpl
@@ -75,6 +76,7 @@ import com.itsaky.androidide.plugins.services.IdeProjectService
import com.itsaky.androidide.plugins.services.IdeSidebarService
import com.itsaky.androidide.plugins.services.IdeSnippetService
import com.itsaky.androidide.plugins.services.IdeTemplateService
+import com.itsaky.androidide.plugins.services.IdeTerminalService
import com.itsaky.androidide.plugins.services.IdeThemeService
import com.itsaky.androidide.plugins.services.IdeTooltipService
import com.itsaky.androidide.plugins.services.IdeUIService
@@ -1627,6 +1629,20 @@ class PluginManager private constructor(
)
}
+ registerServiceWithErrorHandling(
+ pluginServiceRegistry,
+ IdeTerminalService::class.java,
+ pluginId,
+ "terminal",
+ ) {
+ IdeTerminalServiceImpl(
+ pluginId = pluginId,
+ permissions = permissions,
+ projectRootProvider = { projectProvider.getCurrentProject()?.rootDir },
+ appFilesDir = context.filesDir,
+ )
+ }
+
// Note: Phase 2 services (IdeFileService, IdeProjectService, IdeResourceService)
// have been removed. Use existing services instead:
// - IdeFileService for file operations (now includes listFiles)
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/GradleTaskExecution.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/GradleTaskExecution.kt
new file mode 100644
index 0000000000..8c43d322d7
--- /dev/null
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/GradleTaskExecution.kt
@@ -0,0 +1,119 @@
+package com.itsaky.androidide.plugins.manager.services
+
+import com.itsaky.androidide.plugins.extensions.CommandOutput
+import com.itsaky.androidide.plugins.extensions.CommandResult
+import com.itsaky.androidide.plugins.extensions.CommandSpec
+import com.itsaky.androidide.plugins.services.GradleTaskResult
+import com.itsaky.androidide.plugins.services.IdeBuildService
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import java.util.concurrent.CompletableFuture
+
+/**
+ * A [CommandSpec.GradleTask] run on the IDE's tooling server. The output is read from the Build
+ * Output pane once the build ends, since the tooling server streams it there and nowhere else.
+ */
+internal class GradleTaskExecution(
+ override val executionId: String,
+ private val spec: CommandSpec.GradleTask,
+ private val buildService: IdeBuildService,
+ private val timeoutMs: Long,
+) : RunningCommand {
+ private val outputChannel = Channel(capacity = Channel.UNLIMITED)
+ private val resultDeferred = CompletableDeferred()
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ @Volatile
+ private var timedOut = false
+
+ @Volatile
+ private var future: CompletableFuture? = null
+
+ // Also run by cancel(): a build that never completes would otherwise keep its slot.
+ @Volatile
+ private var onComplete: () -> Unit = {}
+
+ override val output: Flow = outputChannel.receiveAsFlow()
+
+ fun start(onComplete: () -> Unit) {
+ this.onComplete = onComplete
+ val startTime = System.currentTimeMillis()
+ val run = buildService.executeTasks(listOf(spec.taskPath), spec.arguments)
+ future = run
+ scope.launch {
+ delay(timeoutMs)
+ timedOut = true
+ if (!run.isDone) buildService.cancelBuild()
+ }
+ run.whenComplete { result, error ->
+ scope.cancel()
+ val duration = System.currentTimeMillis() - startTime
+ val buildOutput = if (result is GradleTaskResult.Refused) "" else buildService.getBuildOutput().orEmpty()
+ val commandResult =
+ when {
+ error != null -> {
+ CommandResult.Failure(-1, buildOutput, "", error.message, duration)
+ }
+
+ result == GradleTaskResult.Success -> {
+ CommandResult.Success(0, buildOutput, "", duration)
+ }
+
+ result == GradleTaskResult.Cancelled && timedOut -> {
+ CommandResult.Failure(-1, buildOutput, "", "Gradle task timed out after ${timeoutMs}ms", duration)
+ }
+
+ result == GradleTaskResult.Cancelled -> {
+ CommandResult.Cancelled(buildOutput, "")
+ }
+
+ result is GradleTaskResult.Refused -> {
+ CommandResult.Failure(-1, "", "", result.reason, duration)
+ }
+
+ result is GradleTaskResult.Failed -> {
+ CommandResult.Failure(1, buildOutput, "", result.reason, duration)
+ }
+
+ else -> {
+ CommandResult.Failure(-1, buildOutput, "", "No result", duration)
+ }
+ }
+ // Blank lines are kept (they separate stack traces); only the final newline's empty tail goes.
+ if (buildOutput.isNotEmpty()) {
+ buildOutput.removeSuffix("\n").lineSequence().forEach { outputChannel.trySend(CommandOutput.StdOut(it)) }
+ }
+ val exitCode =
+ when (commandResult) {
+ is CommandResult.Success -> commandResult.exitCode
+ is CommandResult.Failure -> commandResult.exitCode
+ is CommandResult.Cancelled -> null
+ }
+ exitCode?.let { outputChannel.trySend(CommandOutput.ExitCode(it)) }
+ outputChannel.close()
+ resultDeferred.complete(commandResult)
+ onComplete()
+ }
+ }
+
+ override suspend fun await(): CommandResult = resultDeferred.await()
+
+ override fun cancel() {
+ // Only while this run is the build: once it is done, cancelBuild would stop someone else's.
+ if (future?.isDone == false) buildService.cancelBuild()
+ scope.cancel()
+ outputChannel.close()
+ resultDeferred.complete(CommandResult.Cancelled("", ""))
+ onComplete()
+ }
+
+ override fun isRunning(): Boolean = future?.isDone == false
+}
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImpl.kt
index 8f0b618fd1..a1c58cb1d9 100644
--- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImpl.kt
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImpl.kt
@@ -2,13 +2,23 @@
package com.itsaky.androidide.plugins.manager.services
import com.itsaky.androidide.lookup.Lookup
+import com.itsaky.androidide.project.GradleModels
import com.itsaky.androidide.plugins.services.BuildAndLaunchCallback
import com.itsaky.androidide.plugins.services.BuildStatusListener
import com.itsaky.androidide.plugins.services.GradleSyncCallback
+import com.itsaky.androidide.plugins.services.GradleTaskInfo
+import com.itsaky.androidide.plugins.services.GradleTaskResult
import com.itsaky.androidide.plugins.services.IdeBuildService
+import com.itsaky.androidide.projects.IProjectManager
import com.itsaky.androidide.projects.builder.BuildService
+import com.itsaky.androidide.tooling.api.messages.BuildRunType
+import com.itsaky.androidide.tooling.api.messages.GradleBuildParams
+import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage
+import com.itsaky.androidide.tooling.api.messages.result.BuildCancellationRequestResult
+import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult
import org.slf4j.LoggerFactory
import java.util.concurrent.CompletableFuture
+import java.util.concurrent.CompletionException
import java.util.concurrent.CopyOnWriteArraySet
/**
@@ -30,6 +40,7 @@ class IdeBuildServiceImpl private constructor() : IdeBuildService {
companion object {
private val log = LoggerFactory.getLogger(IdeBuildServiceImpl::class.java)
+ private const val BUILD_IN_PROGRESS_REASON = "another build is in progress"
@Volatile
private var instance: IdeBuildServiceImpl? = null
@@ -38,6 +49,24 @@ class IdeBuildServiceImpl private constructor() : IdeBuildService {
instance ?: synchronized(this) {
instance ?: IdeBuildServiceImpl().also { instance = it }
}
+
+ /** The tasks of [build]'s root project and modules, in that order; empty before a sync. */
+ internal fun tasksOf(build: GradleModels.GradleBuild?): List {
+ if (build == null) return emptyList()
+ val projects = listOfNotNull(build.rootProject.takeIf { build.hasRootProject() }) + build.subProjectList
+ // Sync puts the root in subProjectList too (RootModelBuilder maps every IDEA module).
+ return projects.flatMap { it.taskList }.distinctBy { it.path }.map { it.toInfo() }
+ }
+
+ // Proto3 reads an unset optional string as "", which a plugin would take for a real value.
+ private fun GradleModels.GradleTask.toInfo() =
+ GradleTaskInfo(
+ path = path,
+ name = name,
+ projectPath = projectPath,
+ group = group.takeIf { hasGroup() && it.isNotBlank() },
+ description = description.takeIf { hasDescription() && it.isNotBlank() },
+ )
}
override fun isBuildInProgress(): Boolean = buildInProgress
@@ -109,27 +138,74 @@ class IdeBuildServiceImpl private constructor() : IdeBuildService {
}
}
- override fun executeTasks(vararg tasks: String): CompletableFuture {
+ override fun executeTasks(vararg tasks: String): CompletableFuture =
+ executeTasks(tasks.toList(), emptyList()).thenApply { it == GradleTaskResult.Success }
+
+ override fun executeTasks(
+ tasks: List,
+ arguments: List,
+ ): CompletableFuture {
+ if (tasks.isEmpty()) return refuse(tasks, "no tasks were given")
val buildService =
Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)
?: return refuse(tasks, "build service is not registered")
if (!buildService.isToolingServerStarted()) return refuse(tasks, "tooling server is not started")
- if (buildService.isBuildInProgress) return refuse(tasks, "another build is in progress")
+ if (buildService.isBuildInProgress) return refuse(tasks, BUILD_IN_PROGRESS_REASON)
+
+ val message =
+ TaskExecutionMessage(
+ tasks = tasks,
+ buildId = buildService.nextBuildId(BuildRunType.TaskRun),
+ buildParams = GradleBuildParams(gradleArgs = arguments),
+ )
+ return runCatching { buildService.executeTasks(message) }
+ .getOrElse { CompletableFuture().apply { completeExceptionally(it) } }
+ .handle { result, error -> toGradleTaskResult(tasks, result, error) }
+ }
- return buildService.executeTasks(*tasks).handle { result, error ->
- if (error != null) {
- log.error("Tasks {} failed", tasks.toList(), error)
- }
- error == null && result?.isSuccessful == true
+ private fun toGradleTaskResult(
+ tasks: List,
+ result: TaskExecutionResult?,
+ error: Throwable?,
+ ): GradleTaskResult {
+ if (error != null) {
+ log.error("Tasks {} failed", tasks, error)
+ val cause = (error as? CompletionException)?.cause ?: error
+ return GradleTaskResult.Failed(cause.message ?: cause.javaClass.simpleName)
+ }
+ return when {
+ result == null -> GradleTaskResult.Failed(TaskExecutionResult.Failure.UNKNOWN.name)
+
+ result.isSuccessful -> GradleTaskResult.Success
+
+ result.failure == TaskExecutionResult.Failure.BUILD_CANCELLED -> GradleTaskResult.Cancelled
+
+ // Another build took the slot between the check above and the service's own claim.
+ result.failure == TaskExecutionResult.Failure.BUILD_IN_PROGRESS -> GradleTaskResult.Refused(BUILD_IN_PROGRESS_REASON)
+
+ else -> GradleTaskResult.Failed((result.failure ?: TaskExecutionResult.Failure.UNKNOWN).name)
}
}
private fun refuse(
- tasks: Array,
+ tasks: List,
reason: String,
- ): CompletableFuture {
- log.warn("Not executing tasks {}: {}", tasks.toList(), reason)
- return CompletableFuture.completedFuture(false)
+ ): CompletableFuture {
+ log.warn("Not executing tasks {}: {}", tasks, reason)
+ return CompletableFuture.completedFuture(GradleTaskResult.Refused(reason))
+ }
+
+ override fun cancelBuild(): CompletableFuture {
+ val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)
+ if (buildService == null || !buildService.isToolingServerStarted()) {
+ return CompletableFuture.completedFuture(false)
+ }
+ return runCatching { buildService.cancelCurrentBuild() }
+ .getOrElse { CompletableFuture().apply { completeExceptionally(it) } }
+ .handle { result, error ->
+ if (error != null) log.error("Failed to cancel the running build", error)
+ error == null && result?.wasEnqueued == true
+ }
}
override fun runApp(callback: BuildAndLaunchCallback) {
@@ -144,6 +220,8 @@ class IdeBuildServiceImpl private constructor() : IdeBuildService {
override fun getBuildOutput(): String? = buildOutputProvider?.invoke()
+ override fun getTasks(): List = tasksOf(IProjectManager.getInstance().gradleBuild)
+
/**
* Set the run app provider (should be called by Code On the Go's app module during initialization)
*/
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt
index a334b1b2a4..88856d7a97 100644
--- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt
@@ -5,6 +5,7 @@ import com.itsaky.androidide.plugins.extensions.CommandOutput
import com.itsaky.androidide.plugins.extensions.CommandResult
import com.itsaky.androidide.plugins.extensions.CommandSpec
import com.itsaky.androidide.plugins.services.CommandExecution
+import com.itsaky.androidide.plugins.services.IdeBuildService
import com.itsaky.androidide.plugins.services.IdeCommandService
import com.itsaky.androidide.utils.TermuxProcessEnvironment
import kotlinx.coroutines.CompletableDeferred
@@ -20,8 +21,6 @@ import kotlinx.coroutines.withTimeout
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
-import java.nio.file.Path
-import java.nio.file.Paths
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@@ -30,8 +29,9 @@ class IdeCommandServiceImpl(
private val permissions: Set,
private val projectRootProvider: () -> File?,
private val appFilesDir: File,
+ private val buildService: IdeBuildService = IdeBuildServiceImpl.getInstance(),
) : IdeCommandService {
- private val runningCommands = ConcurrentHashMap()
+ private val runningCommands = ConcurrentHashMap()
override fun executeCommand(
spec: CommandSpec,
@@ -43,36 +43,21 @@ class IdeCommandServiceImpl(
val executionId = "$pluginId-${UUID.randomUUID()}"
val projectRoot = projectRootProvider()
- val processBuilder =
- when (spec) {
- is CommandSpec.ShellCommand -> {
- val workDir =
- when {
- spec.workingDirectory == null -> projectRoot
- Paths.get(spec.workingDirectory).isAbsolute -> File(spec.workingDirectory)
- else -> projectRoot?.let { File(it, spec.workingDirectory).canonicalFile }
- }
- validateWorkingDirectory(workDir)
- ProcessBuilder(listOf(spec.executable) + spec.arguments).apply {
- workDir?.let { directory(it) }
- environment().putAll(spec.environment)
- }
- }
+ if (spec is CommandSpec.GradleTask) {
+ // Through the tooling server, never ./gradlew: a second daemon doubles Gradle's memory
+ // on the device, and its output would never reach the Build Output pane.
+ val execution = GradleTaskExecution(executionId, spec, buildService, timeoutMs)
+ runningCommands[executionId] = execution
+ execution.start { runningCommands.remove(executionId) }
+ return execution
+ }
- is CommandSpec.GradleTask -> {
- val gradleWrapper =
- projectRoot?.let { File(it, "gradlew") }
- ?: throw IllegalStateException("No project root available for Gradle task execution")
- if (!gradleWrapper.exists()) {
- throw IllegalStateException("Gradle wrapper not found at ${gradleWrapper.absolutePath}")
- }
- if (!gradleWrapper.canExecute()) {
- throw IllegalStateException("Gradle wrapper is not executable: ${gradleWrapper.absolutePath}")
- }
- ProcessBuilder(listOf(gradleWrapper.absolutePath, spec.taskPath) + spec.arguments).apply {
- directory(projectRoot)
- }
- }
+ val shell = spec as CommandSpec.ShellCommand
+ val workDir = resolvePluginWorkingDirectory(pluginId, projectRoot, shell.workingDirectory)
+ val processBuilder =
+ ProcessBuilder(listOf(shell.executable) + shell.arguments).apply {
+ workDir?.let { directory(it) }
+ environment().putAll(shell.environment)
}
processBuilder.redirectErrorStream(false)
@@ -92,7 +77,7 @@ class IdeCommandServiceImpl(
override fun isCommandRunning(executionId: String): Boolean = runningCommands[executionId]?.isRunning() == true
override fun cancelCommand(executionId: String): Boolean =
- runningCommands[executionId]?.let {
+ runningCommands.remove(executionId)?.let {
it.cancel()
true
} ?: false
@@ -122,28 +107,20 @@ class IdeCommandServiceImpl(
}
}
- private fun validateWorkingDirectory(dir: File?) {
- if (dir == null) return
- val projectRoot = projectRootProvider() ?: return
- val normalizedDir = dir.canonicalFile.toPath()
- val normalizedRoot = projectRoot.canonicalFile.toPath()
- if (normalizedDir != normalizedRoot && !normalizedDir.startsWith(normalizedRoot)) {
- throw SecurityException(
- "Plugin $pluginId attempted to execute in directory outside project root: $normalizedDir",
- )
- }
- }
-
companion object {
private const val MAX_CONCURRENT_COMMANDS = 3
}
}
+internal interface RunningCommand : CommandExecution {
+ fun isRunning(): Boolean
+}
+
private class CommandExecutionImpl(
override val executionId: String,
private val processBuilder: ProcessBuilder,
private val timeoutMs: Long,
-) : CommandExecution {
+) : RunningCommand {
private val outputChannel = Channel(capacity = Channel.UNLIMITED)
private val resultDeferred = CompletableDeferred()
private val scope = CoroutineScope(Dispatchers.IO + Job())
@@ -151,9 +128,14 @@ private class CommandExecutionImpl(
private val stdoutBuilder = StringBuilder()
private val stderrBuilder = StringBuilder()
+ // Also run by cancel(): a cancel landing before the launched body starts skips its call.
+ @Volatile
+ private var onComplete: () -> Unit = {}
+
override val output: Flow = outputChannel.receiveAsFlow()
fun start(onComplete: () -> Unit) {
+ this.onComplete = onComplete
scope.launch {
val startTime = System.currentTimeMillis()
@@ -240,9 +222,10 @@ private class CommandExecutionImpl(
)
}
scope.cancel()
+ onComplete()
}
- fun isRunning(): Boolean = process?.isAlive == true
+ override fun isRunning(): Boolean = process?.isAlive == true
companion object {
private const val MAX_OUTPUT_BYTES = 10 * 1024 * 1024
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImpl.kt
new file mode 100644
index 0000000000..559caa5dee
--- /dev/null
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImpl.kt
@@ -0,0 +1,99 @@
+package com.itsaky.androidide.plugins.manager.services
+
+import com.itsaky.androidide.plugins.PluginPermission
+import com.itsaky.androidide.plugins.services.IdeTerminalService
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+import com.itsaky.androidide.utils.Environment
+import com.itsaky.androidide.utils.TermuxProcessEnvironment
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import org.slf4j.LoggerFactory
+import java.io.File
+import java.util.concurrent.TimeUnit
+import kotlin.coroutines.resume
+
+/**
+ * Opens a command in a new, visible Terminal session. Implemented by the app module, which owns
+ * the Terminal screen; see [IdeTerminalServiceImpl.setSessionLauncher].
+ */
+fun interface TerminalSessionLauncher {
+ /**
+ * Runs [command] with bash in a new Terminal session named [sessionName], in [workingDirectory]
+ * (the default home directory when null), and calls [onResult] at most once: with
+ * [TerminalCommandResult.Completed] when the command exits, or [TerminalCommandResult.NotStarted]
+ * if the Terminal could not run it. Not called after the returned function kills the command.
+ *
+ * @return a function that kills the command.
+ */
+ fun launch(
+ command: String,
+ workingDirectory: File?,
+ sessionName: String,
+ onResult: (TerminalCommandResult) -> Unit,
+ ): () -> Unit
+}
+
+class IdeTerminalServiceImpl(
+ private val pluginId: String,
+ private val permissions: Set,
+ private val projectRootProvider: () -> File?,
+ private val appFilesDir: File,
+ private val bashProvider: () -> File? = { Environment.BASH_SHELL },
+ private val launcherProvider: () -> TerminalSessionLauncher? = { sessionLauncher },
+) : IdeTerminalService {
+ override suspend fun isTerminalReady(): Boolean =
+ withContext(Dispatchers.IO) {
+ val bash = bashProvider()?.takeIf { it.canExecute() } ?: return@withContext false
+ runCatching {
+ val process =
+ ProcessBuilder(bash.absolutePath, "-c", "exit 0")
+ .redirectErrorStream(true)
+ .apply { TermuxProcessEnvironment.applyTo(environment(), appFilesDir) }
+ .start()
+ try {
+ process.waitFor(READY_TIMEOUT_MS, TimeUnit.MILLISECONDS) && process.exitValue() == 0
+ } finally {
+ // Also on an interrupt, which would otherwise leave bash running.
+ if (process.isAlive) process.destroyForcibly()
+ }
+ }.onFailure { log.warn("Terminal readiness probe failed", it) }
+ .getOrDefault(false)
+ }
+
+ override suspend fun runInTerminal(
+ command: String,
+ workingDirectory: String?,
+ ): TerminalCommandResult {
+ if (PluginPermission.SYSTEM_COMMANDS !in permissions) {
+ throw SecurityException("Plugin $pluginId does not have SYSTEM_COMMANDS permission")
+ }
+ val workDir = resolvePluginWorkingDirectory(pluginId, projectRootProvider(), workingDirectory)
+ if (workDir != null && !workDir.isDirectory) {
+ return TerminalCommandResult.NotStarted("Working directory does not exist: $workDir")
+ }
+ if (bashProvider()?.canExecute() != true) {
+ return TerminalCommandResult.NotStarted("The terminal environment is not installed")
+ }
+ val launcher = launcherProvider() ?: return TerminalCommandResult.NotStarted("The Terminal is not available")
+
+ return suspendCancellableCoroutine { continuation ->
+ val kill = launcher.launch(command, workDir, pluginId) { continuation.resume(it) }
+ continuation.invokeOnCancellation { kill() }
+ }
+ }
+
+ companion object {
+ private val log = LoggerFactory.getLogger(IdeTerminalServiceImpl::class.java)
+
+ private const val READY_TIMEOUT_MS = 5_000L
+
+ @Volatile
+ private var sessionLauncher: TerminalSessionLauncher? = null
+
+ /** Set by the app module during initialization. */
+ fun setSessionLauncher(launcher: TerminalSessionLauncher) {
+ sessionLauncher = launcher
+ }
+ }
+}
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/PluginWorkingDirectory.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/PluginWorkingDirectory.kt
new file mode 100644
index 0000000000..2b94bb1af7
--- /dev/null
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/PluginWorkingDirectory.kt
@@ -0,0 +1,37 @@
+package com.itsaky.androidide.plugins.manager.services
+
+import java.io.File
+import java.nio.file.Paths
+
+/**
+ * Resolves the working directory a plugin asked to run a command in: null means [projectRoot], and
+ * a relative path is taken against it.
+ *
+ * @throws SecurityException if the directory lies outside [projectRoot], or one is given with no
+ * project open.
+ */
+internal fun resolvePluginWorkingDirectory(
+ pluginId: String,
+ projectRoot: File?,
+ workingDirectory: String?,
+): File? {
+ if (workingDirectory != null && projectRoot == null) {
+ throw SecurityException("Plugin $pluginId asked for working directory $workingDirectory with no project open")
+ }
+ val dir =
+ when {
+ workingDirectory == null -> projectRoot
+ Paths.get(workingDirectory).isAbsolute -> File(workingDirectory)
+ else -> File(projectRoot, workingDirectory).canonicalFile
+ }
+ if (dir == null || projectRoot == null) return dir
+
+ val normalizedDir = dir.canonicalFile.toPath()
+ val normalizedRoot = projectRoot.canonicalFile.toPath()
+ if (normalizedDir != normalizedRoot && !normalizedDir.startsWith(normalizedRoot)) {
+ throw SecurityException(
+ "Plugin $pluginId attempted to execute in directory outside project root: $normalizedDir",
+ )
+ }
+ return dir
+}
diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImplExecuteTasksTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImplExecuteTasksTest.kt
index 9e7aa57c1f..5d66bca0fb 100644
--- a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImplExecuteTasksTest.kt
+++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImplExecuteTasksTest.kt
@@ -2,6 +2,7 @@ package com.itsaky.androidide.plugins.manager.services
import com.google.common.truth.Truth.assertThat
import com.itsaky.androidide.lookup.Lookup
+import com.itsaky.androidide.plugins.services.GradleTaskResult
import com.itsaky.androidide.projects.builder.BuildService
import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams
import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage
@@ -18,25 +19,32 @@ class IdeBuildServiceImplExecuteTasksTest {
private class FakeBuildService(
private val serverStarted: Boolean = true,
override val isBuildInProgress: Boolean = false,
+ private val cancellation: () -> CompletableFuture = { error("must not cancel") },
private val result: () -> CompletableFuture,
) : BuildService {
val executed = mutableListOf>()
+ val messages = mutableListOf()
+ var cancelRequests = 0
override fun isToolingServerStarted() = serverStarted
- override fun executeTasks(tasks: List): CompletableFuture {
- executed += tasks
+ override fun executeTasks(tasks: List): CompletableFuture = unsupported()
+
+ override fun executeTasks(message: TaskExecutionMessage): CompletableFuture {
+ executed += message.tasks
+ messages += message
return result()
}
+ override fun cancelCurrentBuild(): CompletableFuture {
+ cancelRequests++
+ return cancellation()
+ }
+
override fun metadata(): CompletableFuture = unsupported()
override fun initializeProject(params: InitializeProjectParams): CompletableFuture = unsupported()
- override fun executeTasks(message: TaskExecutionMessage): CompletableFuture = unsupported()
-
- override fun cancelCurrentBuild(): CompletableFuture = unsupported()
-
private fun unsupported(): Nothing = throw UnsupportedOperationException()
}
@@ -48,6 +56,21 @@ class IdeBuildServiceImplExecuteTasksTest {
.executeTasks(":app:assembleDebug")
.get(5, TimeUnit.SECONDS)
+ private fun run(
+ tasks: List = listOf(":app:testDebugUnitTest"),
+ arguments: List = emptyList(),
+ ): GradleTaskResult =
+ IdeBuildServiceImpl
+ .getInstance()
+ .executeTasks(tasks, arguments)
+ .get(5, TimeUnit.SECONDS)
+
+ private fun cancel(): Boolean =
+ IdeBuildServiceImpl
+ .getInstance()
+ .cancelBuild()
+ .get(5, TimeUnit.SECONDS)
+
@After
fun tearDown() {
Lookup.getDefault().unregister(BuildService.KEY_BUILD_SERVICE)
@@ -110,4 +133,134 @@ class IdeBuildServiceImplExecuteTasksTest {
assertThat(execute()).isFalse()
assertThat(service.executed).isEmpty()
}
+
+ @Test
+ fun argumentsReachTheToolingServer() {
+ val service = register(FakeBuildService { CompletableFuture.completedFuture(TaskExecutionResult.SUCCESS) })
+
+ val result = run(arguments = listOf("--tests", "com.example.FooTest", "-Pflag=true"))
+
+ assertThat(result).isEqualTo(GradleTaskResult.Success)
+ assertThat(service.messages.single().tasks).containsExactly(":app:testDebugUnitTest")
+ assertThat(
+ service.messages
+ .single()
+ .buildParams.gradleArgs,
+ ).containsExactly("--tests", "com.example.FooTest", "-Pflag=true")
+ .inOrder()
+ }
+
+ @Test
+ fun failedBuildReportsTheFailure() {
+ register(
+ FakeBuildService {
+ CompletableFuture.completedFuture(TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_FAILED))
+ },
+ )
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Failed("BUILD_FAILED"))
+ }
+
+ @Test
+ fun cancelledBuildReportsCancelled() {
+ register(
+ FakeBuildService {
+ CompletableFuture.completedFuture(TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_CANCELLED))
+ },
+ )
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Cancelled)
+ }
+
+ @Test
+ fun exceptionalBuildReportsTheCause() {
+ register(
+ FakeBuildService {
+ CompletableFuture().apply { completeExceptionally(IllegalStateException("gradle died")) }
+ },
+ )
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Failed("gradle died"))
+ }
+
+ @Test
+ fun buildInProgressIsRefusedWithTheReason() {
+ val service = register(FakeBuildService(isBuildInProgress = true) { error("must not execute") })
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Refused("another build is in progress"))
+ assertThat(service.executed).isEmpty()
+ }
+
+ @Test
+ fun buildThatTakesTheSlotAfterTheCheckIsRefused() {
+ register(
+ FakeBuildService {
+ CompletableFuture.completedFuture(TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_IN_PROGRESS))
+ },
+ )
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Refused("another build is in progress"))
+ }
+
+ @Test
+ fun stoppedToolingServerIsRefusedWithTheReason() {
+ val service = register(FakeBuildService(serverStarted = false) { error("must not execute") })
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Refused("tooling server is not started"))
+ assertThat(service.executed).isEmpty()
+ }
+
+ @Test
+ fun missingBuildServiceIsRefused() {
+ assertThat(run()).isEqualTo(GradleTaskResult.Refused("build service is not registered"))
+ }
+
+ @Test
+ fun noTasksIsRefusedWithoutExecuting() {
+ val service = register(FakeBuildService { error("must not execute") })
+
+ assertThat(run(tasks = emptyList())).isInstanceOf(GradleTaskResult.Refused::class.java)
+ assertThat(service.executed).isEmpty()
+ }
+
+ @Test
+ fun cancelBuildReportsAnEnqueuedCancellation() {
+ val service =
+ register(
+ FakeBuildService(
+ cancellation = { CompletableFuture.completedFuture(BuildCancellationRequestResult(true)) },
+ ) { error("must not execute") },
+ )
+
+ assertThat(cancel()).isTrue()
+ assertThat(service.cancelRequests).isEqualTo(1)
+ }
+
+ @Test
+ fun cancelBuildReportsFalseWhenNothingIsRunning() {
+ register(
+ FakeBuildService(
+ cancellation = {
+ CompletableFuture.completedFuture(
+ BuildCancellationRequestResult(false, BuildCancellationRequestResult.Reason.NO_RUNNING_BUILD),
+ )
+ },
+ ) { error("must not execute") },
+ )
+
+ assertThat(cancel()).isFalse()
+ }
+
+ @Test
+ fun cancelBuildReportsFalseWithoutAToolingServer() {
+ val service = register(FakeBuildService(serverStarted = false) { error("must not execute") })
+
+ assertThat(cancel()).isFalse()
+ assertThat(service.cancelRequests).isEqualTo(0)
+ }
+
+ @Test
+ fun cancelBuildReportsFalseWithoutABuildService() {
+ assertThat(cancel()).isFalse()
+ }
}
diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImplGetTasksTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImplGetTasksTest.kt
new file mode 100644
index 0000000000..20ab604541
--- /dev/null
+++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImplGetTasksTest.kt
@@ -0,0 +1,97 @@
+package com.itsaky.androidide.plugins.manager.services
+
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.plugins.services.GradleTaskInfo
+import com.itsaky.androidide.project.GradleModels
+import org.junit.Test
+
+class IdeBuildServiceImplGetTasksTest {
+ private fun task(
+ projectPath: String,
+ name: String,
+ group: String? = null,
+ description: String? = null,
+ ): GradleModels.GradleTask =
+ GradleModels.GradleTask
+ .newBuilder()
+ .setName(name)
+ .setPath(if (projectPath == ":") ":$name" else "$projectPath:$name")
+ .setProjectPath(projectPath)
+ .setIsPublic(group != null)
+ .apply { group?.let(::setGroup) }
+ .apply { description?.let(::setDescription) }
+ .build()
+
+ private fun project(
+ path: String,
+ vararg tasks: GradleModels.GradleTask,
+ ): GradleModels.GradleProject =
+ GradleModels.GradleProject
+ .newBuilder()
+ .setName(path.trimStart(':').ifEmpty { "root" })
+ .setPath(path)
+ .addAllTask(tasks.toList())
+ .build()
+
+ @Test
+ fun givenNoSyncedBuild_whenListingTasks_thenReturnsEmpty() {
+ assertThat(IdeBuildServiceImpl.tasksOf(null)).isEmpty()
+ }
+
+ @Test
+ fun givenRootAndModuleTasks_whenListingTasks_thenReturnsRootTasksFirst() {
+ val build =
+ GradleModels.GradleBuild
+ .newBuilder()
+ .setRootProject(project(":", task(":", "printGreeting", "custom", "Prints a greeting")))
+ .addSubProject(project(":app", task(":app", "testDebugUnitTest", "verification", "Run unit tests")))
+ .build()
+
+ assertThat(IdeBuildServiceImpl.tasksOf(build))
+ .containsExactly(
+ GradleTaskInfo(":printGreeting", "printGreeting", ":", "custom", "Prints a greeting"),
+ GradleTaskInfo(":app:testDebugUnitTest", "testDebugUnitTest", ":app", "verification", "Run unit tests"),
+ ).inOrder()
+ }
+
+ @Test
+ fun givenTaskWithoutGroupOrDescription_whenListingTasks_thenReportsNullNotEmpty() {
+ val build =
+ GradleModels.GradleBuild
+ .newBuilder()
+ .addSubProject(project(":app", task(":app", "compileDebugKotlin"), task(":app", "lint", "", " ")))
+ .build()
+
+ val tasks = IdeBuildServiceImpl.tasksOf(build)
+
+ assertThat(tasks.map { it.group }).containsExactly(null, null)
+ assertThat(tasks.map { it.description }).containsExactly(null, null)
+ }
+
+ @Test
+ fun givenRootAlsoInSubProjects_whenListingTasks_thenEachTaskIsListedOnce() {
+ val root = project(":", task(":", "secretHandshake", description = "Prints a greeting"))
+ val build =
+ GradleModels.GradleBuild
+ .newBuilder()
+ .setRootProject(root)
+ .addSubProject(root)
+ .addSubProject(project(":app", task(":app", "secretHandshake", description = "Prints a greeting")))
+ .build()
+
+ assertThat(IdeBuildServiceImpl.tasksOf(build).map { it.path })
+ .containsExactly(":secretHandshake", ":app:secretHandshake")
+ .inOrder()
+ }
+
+ @Test
+ fun givenBuildWithoutRootProject_whenListingTasks_thenReturnsModuleTasksOnly() {
+ val build =
+ GradleModels.GradleBuild
+ .newBuilder()
+ .addSubProject(project(":lib", task(":lib", "assemble", "build")))
+ .build()
+
+ assertThat(IdeBuildServiceImpl.tasksOf(build).map { it.path }).containsExactly(":lib:assemble")
+ }
+}
diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImplGradleTaskTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImplGradleTaskTest.kt
new file mode 100644
index 0000000000..88ed7cc0f6
--- /dev/null
+++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImplGradleTaskTest.kt
@@ -0,0 +1,218 @@
+package com.itsaky.androidide.plugins.manager.services
+
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.plugins.PluginPermission
+import com.itsaky.androidide.plugins.extensions.CommandOutput
+import com.itsaky.androidide.plugins.extensions.CommandResult
+import com.itsaky.androidide.plugins.extensions.CommandSpec
+import com.itsaky.androidide.plugins.services.BuildAndLaunchCallback
+import com.itsaky.androidide.plugins.services.BuildStatusListener
+import com.itsaky.androidide.plugins.services.GradleSyncCallback
+import com.itsaky.androidide.plugins.services.GradleTaskResult
+import com.itsaky.androidide.plugins.services.IdeBuildService
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.Test
+import java.io.File
+import java.util.concurrent.CompletableFuture
+
+class IdeCommandServiceImplGradleTaskTest {
+ private class FakeIdeBuildService(
+ private val buildOutput: String? = "> Task :app:test\nBUILD SUCCESSFUL",
+ private val run: () -> CompletableFuture,
+ ) : IdeBuildService {
+ val runs = mutableListOf, List>>()
+ var cancelRequests = 0
+ var outputReads = 0
+
+ override fun executeTasks(
+ tasks: List,
+ arguments: List,
+ ): CompletableFuture {
+ runs += tasks to arguments
+ return run()
+ }
+
+ override fun cancelBuild(): CompletableFuture {
+ cancelRequests++
+ return CompletableFuture.completedFuture(true)
+ }
+
+ override fun getBuildOutput(): String? {
+ outputReads++
+ return buildOutput
+ }
+
+ override fun isBuildInProgress() = false
+
+ override fun isToolingServerStarted() = true
+
+ override fun addBuildStatusListener(callback: BuildStatusListener) = Unit
+
+ override fun removeBuildStatusListener(callback: BuildStatusListener) = Unit
+
+ override fun runApp(callback: BuildAndLaunchCallback) = Unit
+
+ override fun triggerGradleSync(callback: GradleSyncCallback) = Unit
+ }
+
+ private val spec = CommandSpec.GradleTask(":app:testDebugUnitTest", listOf("--tests", "com.example.FooTest"))
+
+ private fun service(
+ buildService: IdeBuildService,
+ permissions: Set = setOf(PluginPermission.SYSTEM_COMMANDS),
+ ) = IdeCommandServiceImpl(
+ pluginId = "test.plugin",
+ permissions = permissions,
+ // No project and no gradlew: the tooling-server path needs neither.
+ projectRootProvider = { null },
+ appFilesDir = File("unused"),
+ buildService = buildService,
+ )
+
+ private fun await(block: suspend () -> T): T = runBlocking { withTimeout(5_000) { block() } }
+
+ @Test
+ fun gradleTaskRunsOnTheToolingServerWithItsArguments() {
+ val build = FakeIdeBuildService { CompletableFuture.completedFuture(GradleTaskResult.Success) }
+
+ val result = await { service(build).executeCommand(spec).await() }
+
+ assertThat(build.runs)
+ .containsExactly(listOf(":app:testDebugUnitTest") to listOf("--tests", "com.example.FooTest"))
+ assertThat(result).isInstanceOf(CommandResult.Success::class.java)
+ result as CommandResult.Success
+ assertThat(result.exitCode).isEqualTo(0)
+ assertThat(result.stdout).isEqualTo("> Task :app:test\nBUILD SUCCESSFUL")
+ }
+
+ @Test
+ fun outputFlowCarriesTheBuildOutputThenTheExitCode() {
+ val build = FakeIdeBuildService { CompletableFuture.completedFuture(GradleTaskResult.Success) }
+
+ val output = await { service(build).executeCommand(spec).output.toList() }
+
+ assertThat(output)
+ .containsExactly(
+ CommandOutput.StdOut("> Task :app:test"),
+ CommandOutput.StdOut("BUILD SUCCESSFUL"),
+ CommandOutput.ExitCode(0),
+ ).inOrder()
+ }
+
+ @Test
+ fun outputFlowKeepsBlankLinesButNotTheTrailingNewline() {
+ val build =
+ FakeIdeBuildService(buildOutput = "FAILURE\n\n* What went wrong:\n") {
+ CompletableFuture.completedFuture(GradleTaskResult.Success)
+ }
+
+ val output = await { service(build).executeCommand(spec).output.toList() }
+
+ assertThat(output)
+ .containsExactly(
+ CommandOutput.StdOut("FAILURE"),
+ CommandOutput.StdOut(""),
+ CommandOutput.StdOut("* What went wrong:"),
+ CommandOutput.ExitCode(0),
+ ).inOrder()
+ }
+
+ @Test
+ fun failedBuildIsAFailureWithExitCodeOne() {
+ val build = FakeIdeBuildService { CompletableFuture.completedFuture(GradleTaskResult.Failed("BUILD_FAILED")) }
+
+ val result = await { service(build).executeCommand(spec).await() } as CommandResult.Failure
+
+ assertThat(result.exitCode).isEqualTo(1)
+ assertThat(result.error).isEqualTo("BUILD_FAILED")
+ }
+
+ @Test
+ fun refusedBuildIsAFailureCarryingTheReason() {
+ val build =
+ FakeIdeBuildService { CompletableFuture.completedFuture(GradleTaskResult.Refused("another build is in progress")) }
+
+ val result = await { service(build).executeCommand(spec).await() } as CommandResult.Failure
+
+ assertThat(result.exitCode).isEqualTo(-1)
+ assertThat(result.error).isEqualTo("another build is in progress")
+ // A refused run produced no output; the pane holds someone else's build.
+ assertThat(build.outputReads).isEqualTo(0)
+ assertThat(result.stdout).isEmpty()
+ }
+
+ @Test
+ fun cancellingARunningTaskCancelsTheBuild() {
+ val pending = CompletableFuture()
+ val build = FakeIdeBuildService { pending }
+ val service = service(build)
+ val execution = service.executeCommand(spec)
+
+ assertThat(service.isCommandRunning(execution.executionId)).isTrue()
+ execution.cancel()
+
+ assertThat(build.cancelRequests).isEqualTo(1)
+ assertThat(await { execution.await() }).isInstanceOf(CommandResult.Cancelled::class.java)
+ }
+
+ @Test
+ fun cancelledCommandsFreeTheirConcurrencySlots() {
+ // The build never completes, so the completion callback never fires.
+ val build = FakeIdeBuildService { CompletableFuture() }
+ val service = service(build)
+
+ repeat(3) {
+ val execution = service.executeCommand(spec)
+ assertThat(service.cancelCommand(execution.executionId)).isTrue()
+ }
+
+ assertThat(service.getRunningCommandCount()).isEqualTo(0)
+ service.executeCommand(spec)
+ }
+
+ @Test
+ fun cancellingTheExecutionDirectlyFreesItsSlot() {
+ val build = FakeIdeBuildService { CompletableFuture() }
+ val service = service(build)
+
+ service.executeCommand(spec).cancel()
+
+ assertThat(service.getRunningCommandCount()).isEqualTo(0)
+ }
+
+ @Test
+ fun cancellingAFinishedTaskLeavesLaterBuildsAlone() {
+ val build = FakeIdeBuildService { CompletableFuture.completedFuture(GradleTaskResult.Success) }
+ val execution = service(build).executeCommand(spec)
+ await { execution.await() }
+
+ execution.cancel()
+
+ assertThat(build.cancelRequests).isEqualTo(0)
+ }
+
+ @Test
+ fun timeoutCancelsTheBuildAndReportsATimeout() {
+ val pending = CompletableFuture()
+ val build =
+ object : IdeBuildService by FakeIdeBuildService(run = { pending }) {
+ override fun cancelBuild(): CompletableFuture {
+ pending.complete(GradleTaskResult.Cancelled)
+ return CompletableFuture.completedFuture(true)
+ }
+ }
+
+ val result = await { service(build).executeCommand(spec, timeoutMs = 50).await() } as CommandResult.Failure
+
+ assertThat(result.error).contains("timed out")
+ }
+
+ @Test(expected = SecurityException::class)
+ fun gradleTaskStillRequiresSystemCommands() {
+ val build = FakeIdeBuildService { error("must not run") }
+
+ service(build, permissions = emptySet()).executeCommand(spec)
+ }
+}
diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImplTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImplTest.kt
new file mode 100644
index 0000000000..5af77b0b4b
--- /dev/null
+++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImplTest.kt
@@ -0,0 +1,157 @@
+package com.itsaky.androidide.plugins.manager.services
+
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.plugins.PluginPermission
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+import kotlinx.coroutines.async
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.yield
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+
+class IdeTerminalServiceImplTest {
+ @get:Rule
+ val tmp = TemporaryFolder()
+
+ private class FakeLauncher(
+ private val result: TerminalCommandResult? = null,
+ ) : TerminalSessionLauncher {
+ val launches = mutableListOf>()
+ var killed = false
+
+ override fun launch(
+ command: String,
+ workingDirectory: File?,
+ sessionName: String,
+ onResult: (TerminalCommandResult) -> Unit,
+ ): () -> Unit {
+ launches += Triple(command, workingDirectory, sessionName)
+ result?.let(onResult)
+ return { killed = true }
+ }
+ }
+
+ private fun script(exitCode: Int): File =
+ tmp.newFile().apply {
+ writeText("#!/bin/sh\nexit $exitCode\n")
+ setExecutable(true)
+ }
+
+ private fun service(
+ bash: File? = script(0),
+ launcher: TerminalSessionLauncher? = FakeLauncher(TerminalCommandResult.Completed(0, "$ ls")),
+ projectRoot: File? = tmp.root,
+ permissions: Set = setOf(PluginPermission.SYSTEM_COMMANDS),
+ ) = IdeTerminalServiceImpl(
+ pluginId = "test.plugin",
+ permissions = permissions,
+ projectRootProvider = { projectRoot },
+ appFilesDir = tmp.root,
+ bashProvider = { bash },
+ launcherProvider = { launcher },
+ )
+
+ private fun await(block: suspend () -> T): T = runBlocking { withTimeout(5_000) { block() } }
+
+ @Test
+ fun readyWhenBashRunsCleanly() {
+ assertThat(await { service().isTerminalReady() }).isTrue()
+ }
+
+ @Test
+ fun notReadyWhenBashFails() {
+ assertThat(await { service(bash = script(1)).isTerminalReady() }).isFalse()
+ }
+
+ @Test
+ fun notReadyWhenBashIsMissing() {
+ assertThat(await { service(bash = File(tmp.root, "missing")).isTerminalReady() }).isFalse()
+ assertThat(await { service(bash = null).isTerminalReady() }).isFalse()
+ }
+
+ @Test
+ fun runsInTheProjectRootByDefaultAndReturnsTheTranscript() {
+ val launcher = FakeLauncher(TerminalCommandResult.Completed(2, "$ ./check.sh\nboom"))
+
+ val result = await { service(launcher = launcher).runInTerminal("./check.sh") }
+
+ assertThat(result).isEqualTo(TerminalCommandResult.Completed(2, "$ ./check.sh\nboom"))
+ assertThat(launcher.launches).containsExactly(Triple("./check.sh", tmp.root, "test.plugin"))
+ }
+
+ @Test
+ fun relativeWorkingDirectoryIsTakenAgainstTheProjectRoot() {
+ val sub = tmp.newFolder("app")
+ val launcher = FakeLauncher(TerminalCommandResult.Completed(0, ""))
+
+ await { service(launcher = launcher).runInTerminal("ls", workingDirectory = "app") }
+
+ assertThat(launcher.launches.single().second).isEqualTo(sub.canonicalFile)
+ }
+
+ @Test(expected = SecurityException::class)
+ fun workingDirectoryOutsideTheProjectIsRejected() {
+ val project = tmp.newFolder("project")
+
+ await { service(projectRoot = project).runInTerminal("ls", workingDirectory = tmp.root.absolutePath) }
+ }
+
+ @Test(expected = SecurityException::class)
+ fun runningRequiresSystemCommands() {
+ await { service(permissions = emptySet()).runInTerminal("ls") }
+ }
+
+ @Test
+ fun missingWorkingDirectoryDoesNotStart() {
+ val launcher = FakeLauncher()
+
+ val result = await { service(launcher = launcher).runInTerminal("ls", workingDirectory = "nope") }
+
+ assertThat(result).isInstanceOf(TerminalCommandResult.NotStarted::class.java)
+ assertThat(launcher.launches).isEmpty()
+ }
+
+ @Test
+ fun missingTerminalEnvironmentDoesNotStart() {
+ val launcher = FakeLauncher()
+
+ val result = await { service(bash = File(tmp.root, "missing"), launcher = launcher).runInTerminal("ls") }
+
+ assertThat(result).isEqualTo(TerminalCommandResult.NotStarted("The terminal environment is not installed"))
+ assertThat(launcher.launches).isEmpty()
+ }
+
+ @Test
+ fun noLauncherDoesNotStart() {
+ val result = await { service(launcher = null).runInTerminal("ls") }
+
+ assertThat(result).isEqualTo(TerminalCommandResult.NotStarted("The Terminal is not available"))
+ }
+
+ @Test
+ fun launcherRefusalIsReturned() {
+ val launcher = FakeLauncher(TerminalCommandResult.NotStarted("Code On the Go is not in the foreground"))
+
+ val result = await { service(launcher = launcher).runInTerminal("ls") }
+
+ assertThat(result).isEqualTo(TerminalCommandResult.NotStarted("Code On the Go is not in the foreground"))
+ }
+
+ @Test
+ fun cancellingTheCallerKillsTheCommand() {
+ // No result: the command is still running when the caller gives up.
+ val launcher = FakeLauncher(result = null)
+
+ runBlocking {
+ val run = async { service(launcher = launcher).runInTerminal("sleep 100") }
+ while (launcher.launches.isEmpty()) yield()
+ run.cancel()
+ run.join()
+ }
+
+ assertThat(launcher.killed).isTrue()
+ }
+}
diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/PluginWorkingDirectoryTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/PluginWorkingDirectoryTest.kt
new file mode 100644
index 0000000000..a0a5bc5c47
--- /dev/null
+++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/PluginWorkingDirectoryTest.kt
@@ -0,0 +1,59 @@
+package com.itsaky.androidide.plugins.manager.services
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+
+class PluginWorkingDirectoryTest {
+ @get:Rule
+ val tmp = TemporaryFolder()
+
+ private fun resolve(
+ projectRoot: File?,
+ workingDirectory: String?,
+ ) = resolvePluginWorkingDirectory("test.plugin", projectRoot, workingDirectory)
+
+ @Test
+ fun nullMeansTheProjectRoot() {
+ assertThat(resolve(tmp.root, null)).isEqualTo(tmp.root)
+ }
+
+ @Test
+ fun relativePathIsTakenAgainstTheProjectRoot() {
+ assertThat(resolve(tmp.root, "app/src")).isEqualTo(File(tmp.root, "app/src").canonicalFile)
+ }
+
+ @Test
+ fun absolutePathInsideTheProjectIsAllowed() {
+ val inside = File(tmp.root, "app").absolutePath
+
+ assertThat(resolve(tmp.root, inside)).isEqualTo(File(inside))
+ }
+
+ @Test(expected = SecurityException::class)
+ fun relativePathEscapingTheProjectIsRejected() {
+ resolve(tmp.newFolder("project"), "../elsewhere")
+ }
+
+ @Test(expected = SecurityException::class)
+ fun absolutePathOutsideTheProjectIsRejected() {
+ resolve(tmp.newFolder("project"), tmp.root.absolutePath)
+ }
+
+ @Test
+ fun withoutAProjectTheDefaultDirectoryIsUsed() {
+ assertThat(resolve(null, null)).isNull()
+ }
+
+ @Test(expected = SecurityException::class)
+ fun withoutAProjectAnAbsolutePathIsRejected() {
+ resolve(null, tmp.root.absolutePath)
+ }
+
+ @Test(expected = SecurityException::class)
+ fun withoutAProjectARelativePathIsRejected() {
+ resolve(null, "app")
+ }
+}
diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt
index 1943edb4c1..5068175837 100644
--- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt
+++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt
@@ -309,8 +309,8 @@ internal class ToolingApiServerImpl(
builder.setStandardInput("NoOp".byteInputStream())
builder.setStandardError(out)
builder.setStandardOutput(out)
- builder.forTasks(*message.tasks.filter { it.isNotBlank() }.toTypedArray())
- builder.configureFrom(clientConfig, message.buildParams)
+ // Tasks go in the arguments, not forTasks, so a task option in buildParams binds to them.
+ builder.configureFrom(clientConfig, message.buildParams, message.tasks)
this.buildCancellationToken = GradleConnector.newCancellationTokenSource()
builder.withCancellationToken(this.buildCancellationToken!!.token())
diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExts.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExts.kt
index 6b16ab3555..a19bbbfba2 100644
--- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExts.kt
+++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExts.kt
@@ -12,9 +12,15 @@ import java.nio.charset.StandardCharsets
private val logger = LoggerFactory.getLogger("GradleBuildExts")
+/**
+ * Configures this launcher's output, progress and arguments. [tasks] go into the argument list,
+ * between the client's arguments and [buildParams]', as on a command line: a task option such as
+ * `--tests` or `--rerun` binds to the task before it, and `forTasks` would leave it unbound.
+ */
fun ConfigurableLauncher<*>.configureFrom(
clientConfig: ClientGradleBuildConfig? = null,
buildParams: GradleBuildParams? = null,
+ tasks: List = emptyList(),
) {
logger.debug(
"configuring build launcher: hasClientConfig={}, hasBuildParams: {}",
@@ -42,6 +48,8 @@ fun ConfigurableLauncher<*>.configureFrom(
addJvmArguments(clientJvmArgs)
}
+ addArguments(tasks.filter(String::isNotBlank))
+
if (buildParams != null) {
val gradleArgs = buildParams.gradleArgs.filter(String::isNotBlank)
logger.debug("Build Gradle args: {}", gradleArgs)
diff --git a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExtsTest.kt b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExtsTest.kt
new file mode 100644
index 0000000000..e38cb36a2c
--- /dev/null
+++ b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExtsTest.kt
@@ -0,0 +1,69 @@
+package com.itsaky.androidide.tooling.impl.util
+
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.tooling.api.messages.ClientGradleBuildConfig
+import com.itsaky.androidide.tooling.api.messages.GradleBuildParams
+import io.mockk.every
+import io.mockk.mockk
+import org.gradle.tooling.BuildLauncher
+import org.gradle.tooling.events.OperationType
+import org.gradle.tooling.events.ProgressListener
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+/**
+ * The argument order [configureFrom] gives a launcher. A task option such as `--tests` only binds
+ * to a task named before it in the arguments; with the task in `forTasks` instead, Gradle rejects
+ * it as "Unknown command-line option '--tests'" (ADFA-6337).
+ */
+@RunWith(JUnit4::class)
+class GradleBuildExtsTest {
+ private val arguments = mutableListOf()
+
+ private val launcher =
+ mockk(relaxed = true).also { launcher ->
+ // A relaxed mock answers the generic setters with the wrong type; they return the launcher.
+ every { launcher.setStandardError(any()) } returns launcher
+ every { launcher.setStandardOutput(any()) } returns launcher
+ every { launcher.setStandardInput(any()) } returns launcher
+ every {
+ launcher.addProgressListener(any(), any>())
+ } returns launcher
+ every { launcher.addJvmArguments(any>()) } returns launcher
+ every { launcher.addArguments(any>()) } answers {
+ arguments += firstArg>()
+ launcher
+ }
+ }
+
+ @Test
+ fun givenTasksAndATaskOption_whenConfiguring_thenTheTasksComeBetweenClientAndBuildArguments() {
+ launcher.configureFrom(
+ clientConfig = ClientGradleBuildConfig(GradleBuildParams(gradleArgs = listOf("--offline"))),
+ buildParams = GradleBuildParams(gradleArgs = listOf("--tests", "com.example.FooTest")),
+ tasks = listOf(":app:testDebugUnitTest"),
+ )
+
+ assertThat(arguments)
+ .containsExactly("--offline", ":app:testDebugUnitTest", "--tests", "com.example.FooTest")
+ .inOrder()
+ }
+
+ @Test
+ fun givenBlankTasks_whenConfiguring_thenTheyAreDropped() {
+ launcher.configureFrom(
+ buildParams = GradleBuildParams(gradleArgs = listOf("--info")),
+ tasks = listOf("build", " "),
+ )
+
+ assertThat(arguments).containsExactly("build", "--info").inOrder()
+ }
+
+ @Test
+ fun givenNoTasks_whenConfiguring_thenOnlyTheArgumentsArePassed() {
+ launcher.configureFrom(buildParams = GradleBuildParams(gradleArgs = listOf("-Pa=b")))
+
+ assertThat(arguments).containsExactly("-Pa=b")
+ }
+}
diff --git a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt
index 0582c448a5..8496eb94c3 100644
--- a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt
+++ b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt
@@ -52,5 +52,6 @@ data class TaskExecutionResult(
CONNECTION_CLOSED,
CACHE_READ_ERROR,
CACHE_WRITE_ERROR,
+ BUILD_IN_PROGRESS,
}
}
diff --git a/termux/termux-app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt b/termux/termux-app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt
index 66e1608bcc..9f99301cfb 100644
--- a/termux/termux-app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt
+++ b/termux/termux-app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt
@@ -24,9 +24,11 @@ import android.os.IBinder
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
+import com.itsaky.androidide.terminal.TerminalCommandRequests
import com.itsaky.androidide.utils.Environment
import com.termux.app.TermuxActivity
import com.termux.app.TermuxService
+import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession
import com.termux.shared.termux.TermuxConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -38,6 +40,10 @@ class TerminalActivity : TermuxActivity() {
private var pendingWorkingDir: String? = null
private var pendingSessionName: String? = null
private var pendingIsFailsafe: Boolean = false
+ private var pendingCommandRequestId: String? = null
+
+ // True when this instance was started only to run a plugin's command, not recreated or reused.
+ private var launchedForCommand = false
override val navigationBarColor: Int
get() = ContextCompat.getColor(this, android.R.color.black)
@@ -48,6 +54,9 @@ class TerminalActivity : TermuxActivity() {
val controller = WindowCompat.getInsetsController(window, window.decorView)
controller.isAppearanceLightNavigationBars = false
controller.isAppearanceLightStatusBars = false
+ // Read before super: TermuxActivity consumes the intent once its service connects.
+ pendingCommandRequestId = intent?.getStringExtra(TerminalCommandRequests.EXTRA_COMMAND_REQUEST_ID)
+ launchedForCommand = savedInstanceState == null && pendingCommandRequestId != null
super.onCreate(savedInstanceState)
}
@@ -65,6 +74,28 @@ class TerminalActivity : TermuxActivity() {
pendingSessionName = null
pendingIsFailsafe = false
}
+
+ // No service yet: keep the request and launchedForCommand for the next connection.
+ if (termuxService == null) return
+
+ pendingCommandRequestId?.let { requestId ->
+ pendingCommandRequestId = null
+ // Withdrawn or cancelled before its session started: close the window opened for it.
+ if (!runCommand(termuxService, requestId) && launchedForCommand) finishActivityIfNotFinishing()
+ }
+ launchedForCommand = false
+ }
+
+ override fun setupTermuxSessionOnServiceConnected(
+ intent: Intent?,
+ workingDir: String?,
+ sessionName: String?,
+ existingSession: TermuxSession?,
+ launchFailsafe: Boolean
+ ) {
+ // The command gets its own session; a default shell beside it would be left behind.
+ if (launchedForCommand && mTermuxService.isTermuxSessionsEmpty) return
+ super.setupTermuxSessionOnServiceConnected(intent, workingDir, sessionName, existingSession, launchFailsafe)
}
override fun onNewIntent(intent: Intent?) {
@@ -72,6 +103,13 @@ class TerminalActivity : TermuxActivity() {
setIntent(intent)
if (intent == null) return
+ val commandRequestId = intent.getStringExtra(TerminalCommandRequests.EXTRA_COMMAND_REQUEST_ID)
+ if (commandRequestId != null) {
+ val service = mTermuxService
+ if (service != null) runCommand(service, commandRequestId) else pendingCommandRequestId = commandRequestId
+ return
+ }
+
val newWorkingDir = intent.getStringExtra(TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY.EXTRA_SESSION_WORKING_DIR)
val newSessionName = intent.getStringExtra(TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY.EXTRA_SESSION_NAME)
val isFailsafe = intent.getBooleanExtra(TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false)
@@ -86,6 +124,27 @@ class TerminalActivity : TermuxActivity() {
}
}
+ /** Runs a plugin's command in a new session and shows it. Returns false if it did not start. */
+ private fun runCommand(service: TermuxService, requestId: String): Boolean {
+ // Null when already run (a recreated activity sees the same intent again) or withdrawn.
+ val request = TerminalCommandRequests.claim(requestId) ?: return false
+ val newSession = service.createTermuxSession(
+ Environment.BASH_SHELL.absolutePath,
+ arrayOf("-c", TerminalCommandRequests.RUN_SCRIPT, "cogo", request.command),
+ null,
+ request.workingDirectory,
+ false,
+ request.sessionName
+ )
+ if (newSession == null) {
+ request.notStarted("The terminal session could not be started")
+ return false
+ }
+ TerminalCommandRequests.attach(request, newSession.terminalSession)
+ mTermuxTerminalSessionActivityClient.setCurrentSession(newSession.terminalSession)
+ return true
+ }
+
private fun createAndSetSession(
service: TermuxService,
workingDir: String?,
diff --git a/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/TerminalCommandRequests.kt b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/TerminalCommandRequests.kt
new file mode 100644
index 0000000000..207da97ded
--- /dev/null
+++ b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/TerminalCommandRequests.kt
@@ -0,0 +1,108 @@
+package com.itsaky.androidide.terminal
+
+import com.termux.terminal.TerminalSession
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Commands a plugin asked to run in a visible Terminal session.
+ *
+ * The caller [enqueue]s one and starts [com.itsaky.androidide.activities.TerminalActivity] with its
+ * id; the activity [claim]s it and creates the session; the session clients report its exit through
+ * [onSessionFinished].
+ */
+object TerminalCommandRequests {
+ /** Intent extra carrying the id [enqueue] returned. */
+ const val EXTRA_COMMAND_REQUEST_ID = "com.itsaky.androidide.terminal.COMMAND_REQUEST_ID"
+
+ /**
+ * Echoes the command, then runs it, so the session shows what ran. The command is passed as `$1`
+ * rather than spliced into the script, so it needs no quoting.
+ */
+ const val RUN_SCRIPT = "printf '$ %s\\n' \"$1\"; eval \"$1\""
+
+ class Request internal constructor(
+ val id: String,
+ val command: String,
+ val workingDirectory: String?,
+ val sessionName: String,
+ private val onExit: (exitCode: Int, transcript: String) -> Unit,
+ private val onNotStarted: (reason: String) -> Unit,
+ ) {
+ @Volatile
+ internal var session: TerminalSession? = null
+
+ @Volatile
+ internal var cancelled = false
+
+ internal fun exited(
+ exitCode: Int,
+ transcript: String,
+ ) = onExit(exitCode, transcript)
+
+ /** Reports that the session for this request could not be created. */
+ fun notStarted(reason: String) {
+ requests.remove(id)
+ onNotStarted(reason)
+ }
+ }
+
+ // Every request until it ends, so a cancel that lands between claim and attach is not lost.
+ private val requests = ConcurrentHashMap()
+ private val pending = ConcurrentHashMap()
+ private val running = ConcurrentHashMap()
+
+ fun enqueue(
+ command: String,
+ workingDirectory: String?,
+ sessionName: String,
+ onExit: (exitCode: Int, transcript: String) -> Unit,
+ onNotStarted: (reason: String) -> Unit,
+ ): String {
+ val id = UUID.randomUUID().toString()
+ val request = Request(id, command, workingDirectory, sessionName, onExit, onNotStarted)
+ requests[id] = request
+ pending[id] = request
+ return id
+ }
+
+ /**
+ * Removes and returns request [id], or null if it was already claimed or withdrawn. One-shot, so
+ * an activity recreated with the same intent does not run the command twice.
+ */
+ fun claim(id: String): Request? = pending.remove(id)
+
+ /** Withdraws request [id] if no session has claimed it yet. Returns true if it was withdrawn. */
+ fun withdraw(id: String): Boolean = (pending.remove(id) != null).also { if (it) requests.remove(id) }
+
+ /** Records that [session] runs [request]. Call on the main thread, before the session can exit. */
+ fun attach(
+ request: Request,
+ session: TerminalSession,
+ ) {
+ request.session = session
+ running[session] = request
+ if (request.cancelled) session.finishIfRunning()
+ }
+
+ /** Kills the command of request [id], whether or not its session has started. */
+ fun cancel(id: String) {
+ val request = requests[id] ?: return
+ request.cancelled = true
+ if (pending.remove(id) != null) requests.remove(id)
+ // attach reads cancelled after setting session, so one side or the other kills it.
+ request.session?.finishIfRunning()
+ }
+
+ /**
+ * Called by the session clients when [session] exits. Returns true if the session ran a plugin
+ * command; the caller then keeps it open so the user can read what ran.
+ */
+ @JvmStatic
+ fun onSessionFinished(session: TerminalSession): Boolean {
+ val request = running.remove(session) ?: return false
+ requests.remove(request.id)
+ request.exited(session.exitStatus, TerminalTranscript.of(session))
+ return true
+ }
+}
diff --git a/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/TerminalTranscript.kt b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/TerminalTranscript.kt
new file mode 100644
index 0000000000..0b5ede3c8b
--- /dev/null
+++ b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/TerminalTranscript.kt
@@ -0,0 +1,17 @@
+package com.itsaky.androidide.terminal
+
+import com.termux.shared.shell.ShellUtils
+import com.termux.terminal.TerminalSession
+
+/** What a plugin receives as the output of a command it ran in a visible Terminal session. */
+internal object TerminalTranscript {
+ // Anchored to the end so the command's own output can mention the banner text.
+ private val EXIT_BANNER = Regex("""\[Process completed(?: \((?:code|signal) \d+\))? - press Enter]\s*$""")
+
+ /** The finished [session]'s transcript, as the plugin should see it. */
+ fun of(session: TerminalSession): String =
+ stripExitBanner(ShellUtils.getTerminalSessionTranscriptText(session, true, false).orEmpty())
+
+ /** Drops the "[Process completed ...]" line the terminal appends when the process exits. */
+ fun stripExitBanner(transcript: String): String = transcript.replace(EXIT_BANNER, "").trimEnd()
+}
diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java
index cb6552fa93..f2960647a3 100644
--- a/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java
+++ b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionActivityClient.java
@@ -1,5 +1,6 @@
package com.termux.app.terminal;
+import com.itsaky.androidide.terminal.TerminalCommandRequests;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.AlertDialog;
@@ -132,6 +133,9 @@ public void onTitleChanged(@NonNull TerminalSession updatedSession) {
@Override
public void onSessionFinished(@NonNull TerminalSession finishedSession) {
+ // A plugin's command session stays open whatever its exit code, so the user can read it.
+ boolean isIdeCommandSession = TerminalCommandRequests.onSessionFinished(finishedSession);
+
TermuxService service = mActivity.getTermuxService();
if (service == null || service.wantsToStop()) {
@@ -163,13 +167,13 @@ public void onSessionFinished(@NonNull TerminalSession finishedSession) {
if (mActivity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) {
// On Android TV devices we need to use older behaviour because we may
// not be able to have multiple launcher icons.
- if (service.getTermuxSessionsSize() > 1 || isPluginExecutionCommandWithPendingResult) {
+ if (!isIdeCommandSession && (service.getTermuxSessionsSize() > 1 || isPluginExecutionCommandWithPendingResult)) {
removeFinishedSession(finishedSession);
}
} else {
// Once we have a separate launcher icon for the failsafe session, it
// should be safe to auto-close session on exit code '0' or '130'.
- if (finishedSession.getExitStatus() == 0 || finishedSession.getExitStatus() == 130 || isPluginExecutionCommandWithPendingResult) {
+ if (!isIdeCommandSession && (finishedSession.getExitStatus() == 0 || finishedSession.getExitStatus() == 130 || isPluginExecutionCommandWithPendingResult)) {
removeFinishedSession(finishedSession);
}
}
diff --git a/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionServiceClient.java b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionServiceClient.java
index 0eaaa1b8bd..a9a27d186d 100644
--- a/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionServiceClient.java
+++ b/termux/termux-app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionServiceClient.java
@@ -1,5 +1,6 @@
package com.termux.app.terminal;
+import com.itsaky.androidide.terminal.TerminalCommandRequests;
import android.app.Service;
import androidx.annotation.NonNull;
import com.termux.app.TermuxService;
@@ -21,6 +22,12 @@ public TermuxTerminalSessionServiceClient(TermuxService service) {
this.mService = service;
}
+ @Override
+ public void onSessionFinished(@NonNull TerminalSession finishedSession) {
+ // The activity is gone; its client is not there to report a plugin command's exit.
+ TerminalCommandRequests.onSessionFinished(finishedSession);
+ }
+
@Override
public void setTerminalShellPid(@NonNull TerminalSession terminalSession, int pid) {
TermuxSession termuxSession = mService.getTermuxSessionForTerminalSession(terminalSession);
diff --git a/termux/termux-app/src/test/java/com/itsaky/androidide/terminal/TerminalCommandRequestsTest.kt b/termux/termux-app/src/test/java/com/itsaky/androidide/terminal/TerminalCommandRequestsTest.kt
new file mode 100644
index 0000000000..7a1ee90e19
--- /dev/null
+++ b/termux/termux-app/src/test/java/com/itsaky/androidide/terminal/TerminalCommandRequestsTest.kt
@@ -0,0 +1,105 @@
+package com.itsaky.androidide.terminal
+
+import com.google.common.truth.Truth.assertThat
+import com.termux.terminal.TerminalSession
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Test
+
+class TerminalCommandRequestsTest {
+ private val exits = mutableListOf>()
+ private val refusals = mutableListOf()
+
+ private fun enqueue(): String =
+ TerminalCommandRequests.enqueue(
+ command = "./gradlew --version",
+ workingDirectory = "/project",
+ sessionName = "test.plugin",
+ onExit = { code, transcript -> exits += code to transcript },
+ onNotStarted = { refusals += it },
+ )
+
+ private fun session(
+ exitStatus: Int = 0,
+ transcript: String = "",
+ ): TerminalSession =
+ mockk(relaxed = true) {
+ every { this@mockk.exitStatus } returns exitStatus
+ every { emulator.screen.transcriptTextWithFullLinesJoined } returns transcript
+ }
+
+ @Test
+ fun claimIsOneShot() {
+ val id = enqueue()
+
+ assertThat(TerminalCommandRequests.claim(id)?.command).isEqualTo("./gradlew --version")
+ assertThat(TerminalCommandRequests.claim(id)).isNull()
+ }
+
+ @Test
+ fun finishedSessionReportsExitCodeAndTranscriptWithoutTheBanner() {
+ val request = TerminalCommandRequests.claim(enqueue())!!
+ val session = session(exitStatus = 1, transcript = "$ false\n\n[Process completed (code 1) - press Enter]")
+ TerminalCommandRequests.attach(request, session)
+
+ assertThat(TerminalCommandRequests.onSessionFinished(session)).isTrue()
+ assertThat(exits).containsExactly(1 to "$ false")
+ // Reported once; a second finish is not a plugin session any more.
+ assertThat(TerminalCommandRequests.onSessionFinished(session)).isFalse()
+ }
+
+ @Test
+ fun unrelatedSessionIsNotClaimed() {
+ assertThat(TerminalCommandRequests.onSessionFinished(session())).isFalse()
+ assertThat(exits).isEmpty()
+ }
+
+ @Test
+ fun cancelBeforeClaimMeansItNeverRuns() {
+ val id = enqueue()
+
+ TerminalCommandRequests.cancel(id)
+
+ assertThat(TerminalCommandRequests.claim(id)).isNull()
+ }
+
+ @Test
+ fun cancelBetweenClaimAndAttachKillsTheSessionOnAttach() {
+ val id = enqueue()
+ val request = TerminalCommandRequests.claim(id)!!
+ TerminalCommandRequests.cancel(id)
+ val session = session()
+
+ TerminalCommandRequests.attach(request, session)
+
+ verify { session.finishIfRunning() }
+ }
+
+ @Test
+ fun cancelAfterAttachKillsTheSession() {
+ val id = enqueue()
+ val session = session()
+ TerminalCommandRequests.attach(TerminalCommandRequests.claim(id)!!, session)
+
+ TerminalCommandRequests.cancel(id)
+
+ verify { session.finishIfRunning() }
+ }
+
+ @Test
+ fun withdrawOnlyTakesBackAnUnclaimedRequest() {
+ val claimed = enqueue()
+ TerminalCommandRequests.claim(claimed)
+
+ assertThat(TerminalCommandRequests.withdraw(claimed)).isFalse()
+ assertThat(TerminalCommandRequests.withdraw(enqueue())).isTrue()
+ }
+
+ @Test
+ fun notStartedIsReported() {
+ TerminalCommandRequests.claim(enqueue())!!.notStarted("no session")
+
+ assertThat(refusals).containsExactly("no session")
+ }
+}
diff --git a/termux/termux-app/src/test/java/com/itsaky/androidide/terminal/TerminalTranscriptTest.kt b/termux/termux-app/src/test/java/com/itsaky/androidide/terminal/TerminalTranscriptTest.kt
new file mode 100644
index 0000000000..72fc804751
--- /dev/null
+++ b/termux/termux-app/src/test/java/com/itsaky/androidide/terminal/TerminalTranscriptTest.kt
@@ -0,0 +1,21 @@
+package com.itsaky.androidide.terminal
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class TerminalTranscriptTest {
+ @Test
+ fun stripExitBannerKeepsTranscriptsWithoutOne() {
+ assertThat(TerminalTranscript.stripExitBanner("$ ls\nREADME.md\n")).isEqualTo("$ ls\nREADME.md")
+ assertThat(TerminalTranscript.stripExitBanner("$ ls\n[Process completed - press Enter]"))
+ .isEqualTo("$ ls")
+ }
+
+ @Test
+ fun stripExitBannerKeepsBannerTextInTheOutput() {
+ val output = "$ cat log\n[Process completed - press Enter]\nnext line"
+ assertThat(TerminalTranscript.stripExitBanner(output)).isEqualTo(output)
+ assertThat(TerminalTranscript.stripExitBanner("$output\n[Process completed (signal 9) - press Enter]"))
+ .isEqualTo(output)
+ }
+}