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