Repository navigation
ADFA-6373 | Support Gradle task arguments and expose visible terminal sessions #2110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stage
Are you sure you want to change the base?
Changes from all commits
29619cf
8eac5a5
822d366
d182d5f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } |
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| package com.itsaky.androidide.services.builder | ||
|
|
||
| import com.google.common.truth.Truth.assertThat | ||
| import com.itsaky.androidide.tooling.api.IToolingApiServer | ||
| import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult | ||
| import com.itsaky.androidide.utils.Environment | ||
| import io.mockk.every | ||
| import io.mockk.mockk | ||
| import io.mockk.verify | ||
| import org.junit.Before | ||
| import org.junit.Rule | ||
| import org.junit.Test | ||
| import org.junit.rules.TemporaryFolder | ||
| import org.junit.runner.RunWith | ||
| import org.robolectric.Robolectric | ||
| import org.robolectric.RobolectricTestRunner | ||
| import java.io.ByteArrayInputStream | ||
| import java.util.concurrent.CompletableFuture | ||
| import java.util.concurrent.TimeUnit | ||
|
|
||
| /** | ||
| * The Gradle build slot when a second build is requested while one runs. | ||
| * | ||
| * The defects this pins: the slot was checked and taken on a pool thread after the request had | ||
| * already been sent, so two callers could both reach the tooling server; and a refused request | ||
| * still cleared the slot when it completed, while the first build was running. | ||
| */ | ||
| @RunWith(RobolectricTestRunner::class) | ||
| class GradleBuildServiceSlotTest { | ||
| @get:Rule | ||
| val tmp = TemporaryFolder() | ||
|
|
||
| private lateinit var service: GradleBuildService | ||
| private val rpc = CompletableFuture<TaskExecutionResult>() | ||
|
|
||
| // A second request that reaches the server is refused there, as ToolingApiServerImpl does. | ||
| private val server = | ||
| mockk<IToolingApiServer> { | ||
| every { executeTasks(any()) } returnsMany | ||
| listOf(rpc, CompletableFuture.failedFuture(IllegalStateException("Build is already in progress"))) | ||
| } | ||
|
|
||
| @Before | ||
| fun setUp() { | ||
| Environment.TMP_DIR = tmp.newFolder("tmp") | ||
| service = Robolectric.buildService(GradleBuildService::class.java).get() | ||
| service.onListenerStarted(server, ByteArrayInputStream(ByteArray(0))) | ||
| } | ||
|
|
||
| @Test | ||
| fun `a second build is refused without reaching the tooling server`() { | ||
| service.executeTasks(listOf(":app:assembleDebug")) | ||
| val second = service.executeTasks(listOf(":app:test")) | ||
|
|
||
| // Distinct from a failed build, which completes as null. | ||
| assertThat(second.get(5, TimeUnit.SECONDS)) | ||
| .isEqualTo(TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_IN_PROGRESS)) | ||
| verify(exactly = 1) { server.executeTasks(any()) } | ||
| } | ||
|
|
||
| @Test | ||
| fun `a refused build leaves the running build's slot taken`() { | ||
| val first = service.executeTasks(listOf(":app:assembleDebug")) | ||
| service.executeTasks(listOf(":app:test")).get(5, TimeUnit.SECONDS) | ||
|
|
||
| assertThat(service.isBuildInProgress).isTrue() | ||
|
|
||
| rpc.complete(TaskExecutionResult(isSuccessful = true, failure = null)) | ||
| first.get(5, TimeUnit.SECONDS) | ||
| assertThat(service.isBuildInProgress).isFalse() | ||
| } | ||
|
|
||
| @Test | ||
| fun `a request that fails to send frees the slot`() { | ||
| every { server.executeTasks(any()) } throws IllegalStateException("closed") | ||
|
|
||
| runCatching { service.executeTasks(listOf(":app:assembleDebug")) } | ||
|
|
||
| assertThat(service.isBuildInProgress).isFalse() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<String>, arguments: List<String>)` 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. F07 (Medium):
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MINOR: Still open at Fix: document both changes under 26.41, or keep the vararg overload on |
||
| `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`. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MINOR: This now says the service refuses a second build silently, but a build that loses the slot claim shows the user "Task execution failed: BUILD_IN_PROGRESS".
GradleBuildService now answers a lost claim with
TaskExecutionResult(false, BUILD_IN_PROGRESS)(GradleBuildService.kt:841).BuildViewModel.kt:137, outside this diff, turns any non-cancelled failure intoRuntimeException("Task execution failed: ${result?.failure}"). On stage the same race threwBuildInProgressException("A build is already running!"), which read fine. It only happens when another build claims the slot between this action'sisBuildInProgresscheck andexecuteTasks, so it is rare.Fix: map
BUILD_IN_PROGRESSin BuildViewModel to the slot-busy message, asBUILD_CANCELLEDis at line 132, and correct this comment.