From 29619cf0c653c6bbab76094c91d4edcaeea430ab Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 6 Oct 2026 09:08:45 -0500 Subject: [PATCH 1/4] style: spotless reformat, no functional change --- .../extensions/BuildActionExtension.kt | 170 ++++++++++-------- 1 file changed, 100 insertions(+), 70 deletions(-) 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..250155d2b4 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,120 @@ 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() + 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() } 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 } From 8eac5a5f541a16f7daa028f688858b565af11a39 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 6 Oct 2026 09:14:33 -0500 Subject: [PATCH 2/4] ADFA-6373: Run plugin Gradle tasks with arguments on the tooling server IdeBuildService gains executeTasks(tasks, arguments), which runs on the IDE's tooling server with the arguments as Gradle args and completes with a GradleTaskResult: Success, Failed(reason), Refused(reason) when the build never started, or Cancelled. cancelBuild() cancels the running build. executeTasks(vararg) now delegates to it and keeps its Boolean contract. CommandSpec.GradleTask runs through the same path instead of ./gradlew, so it no longer starts a second Gradle daemon and its output reaches the Build Output pane. The command reports that output once the build ends, exit code 0 or 1, and a refusal as exit code -1 with the reason. Cancelling or timing out the command cancels the build only while it is still running. --- .../androidide/actions/BaseBuildAction.kt | 6 +- .../CredentialProtectedApplicationLoader.kt | 2 + .../androidide/app/PluginTerminalLauncher.kt | 76 ++++++ .../builder/BuildInProgressException.java | 34 --- .../services/builder/GradleBuildService.kt | 51 ++-- .../GradleBuildServiceServerExitTest.kt | 3 +- .../builder/GradleBuildServiceSlotTest.kt | 79 +++++++ docs/PLUGIN_API_CHANGELOG.md | 29 +++ docs/plugin-api.md | 2 +- plugin-api/api/plugin-api.api | 80 +++++++ .../extensions/BuildActionExtension.kt | 15 ++ .../plugins/services/IdeServices.kt | 53 +++++ .../plugins/services/IdeTerminalService.kt | 51 ++++ .../plugins/manager/core/PluginManager.kt | 16 ++ .../manager/services/GradleTaskExecution.kt | 119 ++++++++++ .../manager/services/IdeBuildServiceImpl.kt | 68 +++++- .../manager/services/IdeCommandServiceImpl.kt | 77 +++---- .../services/IdeTerminalServiceImpl.kt | 99 ++++++++ .../services/PluginWorkingDirectory.kt | 37 +++ .../IdeBuildServiceImplExecuteTasksTest.kt | 154 ++++++++++++- .../IdeCommandServiceImplGradleTaskTest.kt | 218 ++++++++++++++++++ .../services/IdeTerminalServiceImplTest.kt | 157 +++++++++++++ .../services/PluginWorkingDirectoryTest.kt | 59 +++++ .../tooling/impl/ToolingApiServerImpl.kt | 4 +- .../tooling/impl/util/GradleBuildExts.kt | 8 + .../tooling/impl/util/GradleBuildExtsTest.kt | 69 ++++++ .../androidide/activities/TerminalActivity.kt | 59 +++++ .../terminal/TerminalCommandRequests.kt | 108 +++++++++ .../androidide/terminal/TerminalTranscript.kt | 17 ++ .../TermuxTerminalSessionActivityClient.java | 8 +- .../TermuxTerminalSessionServiceClient.java | 7 + .../terminal/TerminalCommandRequestsTest.kt | 105 +++++++++ .../terminal/TerminalTranscriptTest.kt | 21 ++ 33 files changed, 1761 insertions(+), 130 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/app/PluginTerminalLauncher.kt delete mode 100644 app/src/main/java/com/itsaky/androidide/services/builder/BuildInProgressException.java create mode 100644 app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceSlotTest.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeTerminalService.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/GradleTaskExecution.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImpl.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/PluginWorkingDirectory.kt create mode 100644 plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImplGradleTaskTest.kt create mode 100644 plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImplTest.kt create mode 100644 plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/PluginWorkingDirectoryTest.kt create mode 100644 subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExtsTest.kt create mode 100644 termux/termux-app/src/main/java/com/itsaky/androidide/terminal/TerminalCommandRequests.kt create mode 100644 termux/termux-app/src/main/java/com/itsaky/androidide/terminal/TerminalTranscript.kt create mode 100644 termux/termux-app/src/test/java/com/itsaky/androidide/terminal/TerminalCommandRequestsTest.kt create mode 100644 termux/termux-app/src/test/java/com/itsaky/androidide/terminal/TerminalTranscriptTest.kt 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..99654e188d 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 @@ -94,6 +94,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 +112,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 +238,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 +817,7 @@ class GradleBuildService : checkServerStarted() Objects.requireNonNull(params) return try { - performBuildTasks(server!!.initialize(params)) + performBuildTasks { server!!.initialize(params) } } catch (_: ScanPluginMissingException) { log.info("Retrying initialization without --scan option...") initializeProject(params) @@ -836,7 +836,7 @@ class GradleBuildService : override fun executeTasks(message: TaskExecutionMessage): CompletableFuture { checkServerStarted() - val future = performBuildTasks(server!!.executeTasks(message)) + val future = performBuildTasks { server!!.executeTasks(message) } return future.handle { result, exception -> if (exception != null) { @@ -856,9 +856,25 @@ class GradleBuildService : return server!!.cancelCurrentBuild() } - private fun performBuildTasks(future: CompletableFuture): CompletableFuture { + private fun performBuildTasks(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() + // Null, as a failed build completes here: markBuildAsFinished drops the error. + return CompletableFuture.completedFuture(null) + } + 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 +933,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 +953,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..702d92d3a0 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceSlotTest.kt @@ -0,0 +1,79 @@ +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")) + + assertThat(second.get(5, TimeUnit.SECONDS)).isNull() + 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..814dd7650b 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -1610,6 +1610,45 @@ public abstract interface class com/itsaky/androidide/plugins/services/GradleSyn public abstract fun onComplete (ZLjava/lang/String;)V } +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,6 +1659,8 @@ 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 isBuildInProgress ()Z @@ -1630,7 +1671,10 @@ 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 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 +1827,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 +2147,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 250155d2b4..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 @@ -70,6 +70,11 @@ data class PluginBuildAction( ) sealed class 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(), @@ -77,6 +82,16 @@ sealed class CommandSpec { 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(), 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..9f707c91e5 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 @@ -416,6 +445,30 @@ interface IdeBuildService { fun getBuildOutput(): String? = null } +/** + * 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() +} + /** * Callback interface for build and launch operations. */ 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..5c5cec30e1 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 @@ -5,10 +5,17 @@ import com.itsaky.androidide.lookup.Lookup 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 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 /** @@ -109,27 +116,68 @@ 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") - return buildService.executeTasks(*tasks).handle { result, error -> - if (error != null) { - log.error("Tasks {} failed", tasks.toList(), error) - } - error == null && result?.isSuccessful == true + 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) } + } + + 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 + 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) { 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..0c753776ce 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,123 @@ 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 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/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/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) + } +} From 822d366e3acbc651c61d0fb528a68db1e911c6d7 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 6 Oct 2026 16:41:25 -0500 Subject: [PATCH 3/4] ADFA-6373: Report a build refused at the slot claim as Refused A plugin task request could pass IdeBuildServiceImpl's isBuildInProgress check and then lose GradleBuildService's atomic slot claim. The claim completed with null, which is also how a failed build completes, so the plugin got Failed("UNKNOWN") and GradleTaskExecution reported the other build's output with exit code 1. The refused claim now completes with a BUILD_IN_PROGRESS failure (an InitializeResult.Failure for a sync), which IdeBuildServiceImpl maps to Refused. App callers already treat null and unsuccessful results alike. --- .../services/builder/GradleBuildService.kt | 18 +++++++++++++----- .../builder/GradleBuildServiceSlotTest.kt | 4 +++- .../manager/services/IdeBuildServiceImpl.kt | 9 ++++++++- .../IdeBuildServiceImplExecuteTasksTest.kt | 11 +++++++++++ .../api/messages/result/TaskExecutionResult.kt | 1 + 5 files changed, 36 insertions(+), 7 deletions(-) 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 99654e188d..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 @@ -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,13 +860,17 @@ class GradleBuildService : return server!!.cancelCurrentBuild() } - private fun performBuildTasks(dispatch: () -> 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() - // Null, as a failed build completes here: markBuildAsFinished drops the error. - return CompletableFuture.completedFuture(null) + // 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 { 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 index 702d92d3a0..9b92009b81 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceSlotTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceSlotTest.kt @@ -52,7 +52,9 @@ class GradleBuildServiceSlotTest { service.executeTasks(listOf(":app:assembleDebug")) val second = service.executeTasks(listOf(":app:test")) - assertThat(second.get(5, TimeUnit.SECONDS)).isNull() + // 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()) } } 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 5c5cec30e1..ab18cacf00 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 @@ -37,6 +37,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 @@ -128,7 +129,7 @@ class IdeBuildServiceImpl private constructor() : IdeBuildService { 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( @@ -153,8 +154,14 @@ class IdeBuildServiceImpl private constructor() : IdeBuildService { } 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) } } 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 0c753776ce..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 @@ -191,6 +191,17 @@ class IdeBuildServiceImplExecuteTasksTest { 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") }) 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, } } From d182d5f3a90a9ae8971ffe92c4eeebbd3173a8aa Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 6 Oct 2026 18:30:27 -0500 Subject: [PATCH 4/4] ADFA-6373: List the project's Gradle tasks for plugins IdeBuildService gains getTasks(), which returns every task of the root project and its modules from the last sync as GradleTaskInfo (path, name, project path, group, description), each task once and blank fields as null. --- plugin-api/api/plugin-api.api | 21 ++++ .../plugins/services/IdeServices.kt | 24 +++++ .../manager/services/IdeBuildServiceImpl.kt | 23 +++++ .../IdeBuildServiceImplGetTasksTest.kt | 97 +++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImplGetTasksTest.kt diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index 814dd7650b..9ac8d0a975 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -1610,6 +1610,25 @@ 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 { } @@ -1663,6 +1682,7 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeBuildS 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 @@ -1676,6 +1696,7 @@ public final class com/itsaky/androidide/plugins/services/IdeBuildService$Defaul 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 } 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 9f707c91e5..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 @@ -443,8 +443,32 @@ 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. */ 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 ab18cacf00..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,11 +2,14 @@ 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 @@ -46,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 @@ -199,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/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") + } +}