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..6dbb0f334c 100644
--- a/app/src/main/java/com/itsaky/androidide/actions/BaseBuildAction.kt
+++ b/app/src/main/java/com/itsaky/androidide/actions/BaseBuildAction.kt
@@ -49,8 +49,8 @@ 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
+ * slot, and flashes why. [prepare] leaves these actions enabled in that window, and the service
+ * would refuse a second build, which surfaces only as a generic "A build is already running!"
* error. Reads the raw [BuildService.isBuildInProgress], because this guards the slot rather
* than presenting state.
*
diff --git a/app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt b/app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt
index 97d74eddf5..ecaf155779 100644
--- a/app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt
+++ b/app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt
@@ -6,11 +6,16 @@ import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable
import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.itsaky.androidide.actions.ActionData
import com.itsaky.androidide.actions.ActionItem
import com.itsaky.androidide.actions.BaseBuildAction
import com.itsaky.androidide.actions.getContext
+import com.itsaky.androidide.activities.editor.EditorHandlerActivity
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.manager.build.PluginBuildActionManager
import com.itsaky.androidide.plugins.manager.build.RegisteredBuildAction
import com.itsaky.androidide.plugins.manager.core.PluginManager
@@ -19,135 +24,154 @@ import com.itsaky.androidide.plugins.services.IdeCommandService
import com.itsaky.androidide.resources.R
import com.itsaky.androidide.utils.resolveAttr
import com.itsaky.androidide.viewmodel.BottomSheetViewModel
-import com.google.android.material.bottomsheet.BottomSheetBehavior
-import androidx.lifecycle.lifecycleScope
-import com.itsaky.androidide.activities.editor.EditorHandlerActivity
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class PluginBuildActionItem(
- context: Context,
- private val registered: RegisteredBuildAction,
- override val order: Int
+ context: Context,
+ private val registered: RegisteredBuildAction,
+ override val order: Int,
) : BaseBuildAction() {
-
- override val id: String = "plugin.build.${registered.pluginId}.${registered.action.id}"
-
- init {
- label = registered.action.name
- icon = resolvePluginIcon(context)
- location = ActionItem.Location.EDITOR_TOOLBAR
- requiresUIThread = true
- }
-
- override fun prepare(data: ActionData) {
- val context = data.getActivity()
- if (context == null) {
- visible = false
- return
- }
- visible = true
-
- val manager = PluginBuildActionManager.getInstance()
- val isRunning = manager.isActionRunning(registered.pluginId, registered.action.id)
-
- if (isRunning) {
- label = "Cancel ${registered.action.name}"
- icon = ContextCompat.getDrawable(context, R.drawable.ic_stop)
- enabled = true
- } else {
- label = registered.action.name
- icon = resolvePluginIcon(context)
- enabled = true
- }
- }
-
- override fun createColorFilter(data: ActionData): ColorFilter? {
- val context = data.getContext() ?: return null
- val isRunning = PluginBuildActionManager.getInstance()
- .isActionRunning(registered.pluginId, registered.action.id)
- val attr = if (isRunning) R.attr.colorError else R.attr.colorOnSurface
- return PorterDuffColorFilter(
- context.resolveAttr(attr),
- PorterDuff.Mode.SRC_ATOP
- )
- }
-
- private fun resolvePluginIcon(fallbackContext: Context): Drawable? {
- val iconResId = registered.action.icon ?: return ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline)
- return PluginDrawableResolver.resolve(iconResId, registered.pluginId, fallbackContext)
- ?: ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline)
- }
-
- override suspend fun execAction(data: ActionData): Any {
- val manager = PluginBuildActionManager.getInstance()
- val pluginId = registered.pluginId
- val actionId = registered.action.id
-
- if (manager.isActionRunning(pluginId, actionId)) {
- manager.cancelAction(pluginId, actionId)
- data.getActivity()?.let { resetProgressIfIdle(it) }
- return true
- }
-
- val activity = data.getActivity() ?: return false
-
- val pluginManager = PluginManager.getInstance() ?: return false
- val loadedPlugin = pluginManager.getLoadedPlugin(pluginId) ?: return false
- val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java)
- ?: return false
-
- val execution = manager.executeAction(pluginId, actionId, commandService) ?: return false
-
- activity.editorViewModel.isBuildInProgress = true
- val currentSheetState = activity.bottomSheetViewModel.sheetBehaviorState
- val targetState = if (currentSheetState == BottomSheetBehavior.STATE_HIDDEN)
- BottomSheetBehavior.STATE_COLLAPSED else currentSheetState
- activity.bottomSheetViewModel.setSheetState(
- sheetState = targetState,
- currentTab = BottomSheetViewModel.TAB_BUILD_OUTPUT
- )
- activity.appendBuildOutput("━━━ ${registered.action.name} ━━━")
- activity.invalidateOptionsMenu()
-
- activity.lifecycleScope.launch(Dispatchers.Default) {
- runCatching {
- execution.output.collect { output ->
- val line = when (output) {
- is CommandOutput.StdOut -> output.line
- is CommandOutput.StdErr -> output.line
- is CommandOutput.ExitCode ->
- if (output.code != 0) "Process failed with code ${output.code}" else null
- }
- if (line != null) {
- withContext(Dispatchers.Main) {
- activity.appendBuildOutput(line)
- }
- }
- }
-
- val result = execution.await()
- manager.notifyActionCompleted(pluginId, actionId, result)
- withContext(Dispatchers.Main) { resetProgressIfIdle(activity) }
- }.onFailure { e ->
- if (e is CancellationException) {
- manager.cancelAction(pluginId, actionId)
- throw e
- }
- withContext(Dispatchers.Main) { resetProgressIfIdle(activity) }
- }
- }
-
- return true
- }
-
- private fun resetProgressIfIdle(activity: EditorHandlerActivity) {
- val manager = PluginBuildActionManager.getInstance()
- if (buildService?.isBuildInProgress != true && !manager.hasActiveExecutions()) {
- activity.editorViewModel.isBuildInProgress = false
- }
- activity.invalidateOptionsMenu()
- }
+ override val id: String = "plugin.build.${registered.pluginId}.${registered.action.id}"
+
+ init {
+ label = registered.action.name
+ icon = resolvePluginIcon(context)
+ location = ActionItem.Location.EDITOR_TOOLBAR
+ requiresUIThread = true
+ }
+
+ override fun prepare(data: ActionData) {
+ val context = data.getActivity()
+ if (context == null) {
+ visible = false
+ return
+ }
+ visible = true
+
+ val manager = PluginBuildActionManager.getInstance()
+ val isRunning = manager.isActionRunning(registered.pluginId, registered.action.id)
+
+ if (isRunning) {
+ label = "Cancel ${registered.action.name}"
+ icon = ContextCompat.getDrawable(context, R.drawable.ic_stop)
+ enabled = true
+ } else {
+ label = registered.action.name
+ icon = resolvePluginIcon(context)
+ enabled = true
+ }
+ }
+
+ override fun createColorFilter(data: ActionData): ColorFilter? {
+ val context = data.getContext() ?: return null
+ val isRunning =
+ PluginBuildActionManager
+ .getInstance()
+ .isActionRunning(registered.pluginId, registered.action.id)
+ val attr = if (isRunning) R.attr.colorError else R.attr.colorOnSurface
+ return PorterDuffColorFilter(
+ context.resolveAttr(attr),
+ PorterDuff.Mode.SRC_ATOP,
+ )
+ }
+
+ private fun resolvePluginIcon(fallbackContext: Context): Drawable? {
+ val iconResId = registered.action.icon ?: return ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline)
+ return PluginDrawableResolver.resolve(iconResId, registered.pluginId, fallbackContext)
+ ?: ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline)
+ }
+
+ override suspend fun execAction(data: ActionData): Any {
+ val manager = PluginBuildActionManager.getInstance()
+ val pluginId = registered.pluginId
+ val actionId = registered.action.id
+
+ if (manager.isActionRunning(pluginId, actionId)) {
+ manager.cancelAction(pluginId, actionId)
+ data.getActivity()?.let { resetProgressIfIdle(it) }
+ return true
+ }
+
+ val activity = data.getActivity() ?: return false
+
+ val pluginManager = PluginManager.getInstance() ?: return false
+ val loadedPlugin = pluginManager.getLoadedPlugin(pluginId) ?: return false
+ val commandService =
+ loadedPlugin.context.services.get(IdeCommandService::class.java)
+ ?: return false
+
+ val execution = manager.executeAction(pluginId, actionId, commandService) ?: return false
+
+ activity.editorViewModel.isBuildInProgress = true
+ val currentSheetState = activity.bottomSheetViewModel.sheetBehaviorState
+ val targetState =
+ if (currentSheetState == BottomSheetBehavior.STATE_HIDDEN) {
+ BottomSheetBehavior.STATE_COLLAPSED
+ } else {
+ currentSheetState
+ }
+ activity.bottomSheetViewModel.setSheetState(
+ sheetState = targetState,
+ currentTab = BottomSheetViewModel.TAB_BUILD_OUTPUT,
+ )
+ // The tooling server streams a Gradle task's output into the pane itself, and clears the
+ // pane when the build starts, so a header would be wiped.
+ val echoOutput = registered.action.command !is CommandSpec.GradleTask
+ if (echoOutput) activity.appendBuildOutput("━━━ ${registered.action.name} ━━━")
+ activity.invalidateOptionsMenu()
+
+ activity.lifecycleScope.launch(Dispatchers.Default) {
+ runCatching {
+ execution.output.collect { output ->
+ if (!echoOutput) return@collect
+ val line =
+ when (output) {
+ is CommandOutput.StdOut -> {
+ output.line
+ }
+
+ is CommandOutput.StdErr -> {
+ output.line
+ }
+
+ is CommandOutput.ExitCode -> {
+ if (output.code != 0) "Process failed with code ${output.code}" else null
+ }
+ }
+ if (line != null) {
+ withContext(Dispatchers.Main) {
+ activity.appendBuildOutput(line)
+ }
+ }
+ }
+
+ val result = execution.await()
+ // A refused or timed-out task never reached the pane, so say why here.
+ if (!echoOutput && result is CommandResult.Failure && result.exitCode == -1) {
+ result.error?.let { withContext(Dispatchers.Main) { activity.appendBuildOutput(it) } }
+ }
+ manager.notifyActionCompleted(pluginId, actionId, result)
+ withContext(Dispatchers.Main) { resetProgressIfIdle(activity) }
+ }.onFailure { e ->
+ if (e is CancellationException) {
+ manager.cancelAction(pluginId, actionId)
+ throw e
+ }
+ withContext(Dispatchers.Main) { resetProgressIfIdle(activity) }
+ }
+ }
+
+ return true
+ }
+
+ private fun resetProgressIfIdle(activity: EditorHandlerActivity) {
+ val manager = PluginBuildActionManager.getInstance()
+ if (buildService?.isBuildInProgress != true && !manager.hasActiveExecutions()) {
+ activity.editorViewModel.isBuildInProgress = false
+ }
+ activity.invalidateOptionsMenu()
+ }
}
diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt
index fd3f3f2376..93713a0063 100644
--- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt
+++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt
@@ -106,6 +106,7 @@ import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams
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_CANCELLED
+import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.BUILD_IN_PROGRESS
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.CACHE_READ_ERROR
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_DIRECTORY_INACCESSIBLE
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_DIRECTORY
@@ -1703,6 +1704,7 @@ abstract class ProjectHandlerActivity :
PROJECT_NOT_DIRECTORY -> string.msg_file_is_not_dir
PROJECT_NOT_FOUND -> string.msg_project_dir_doesnt_exist
CACHE_READ_ERROR -> string.msg_project_cache_read_failure
+ BUILD_IN_PROGRESS -> string.build_in_progress_warning
else -> null
}?.let {
"$initFailed: ${getString(it)}"
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..b52a1a9cc5 100644
--- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt
+++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt
@@ -21,10 +21,13 @@ import com.itsaky.androidide.managers.ToolsManager
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.IdeBuildServiceImpl
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
+import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult
import com.itsaky.androidide.ui.themes.IDETheme
import com.itsaky.androidide.ui.themes.IThemeManager
import com.itsaky.androidide.utils.EditorDecorationBridge
@@ -393,15 +396,14 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader {
setupBuildServiceProviders()
setupProjectManipulationProviders()
IdeLogServiceImpl.getInstance().setLogReader(LogsProvider::read)
+ IdeTerminalServiceImpl.setSessionLauncher(PluginTerminalLauncher { application.foregroundActivity })
logger.info("Plugin services configured successfully")
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun setupBuildServiceProviders() {
- val buildServiceImpl =
- com.itsaky.androidide.plugins.manager.services.IdeBuildServiceImpl
- .getInstance()
+ val buildServiceImpl = IdeBuildServiceImpl.getInstance()
// Provide runApp functionality
buildServiceImpl.setRunAppProvider { callback ->
@@ -423,7 +425,12 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader {
val result = buildService.executeTasks(listOf("generateDebugSources")).get()
if (result == null || !result.isSuccessful) {
- val errorMsg = result?.failure?.toString() ?: "Unknown error"
+ val errorMsg =
+ when (val failure = result?.failure) {
+ null -> "Unknown error"
+ TaskExecutionResult.Failure.BUILD_IN_PROGRESS -> IdeBuildServiceImpl.BUILD_IN_PROGRESS_REASON
+ else -> failure.toString()
+ }
logger.error("Gradle sync failed: {}", errorMsg)
callback.onComplete(false, "Gradle sync failed: $errorMsg")
} else {
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..89e522fb87
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/app/PluginTerminalLauncher.kt
@@ -0,0 +1,86 @@
+package com.itsaky.androidide.app
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Handler
+import android.os.Looper
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+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. foregroundActivity() still returns a backgrounded
+ // activity (it is cleared only on finish or destroy), and a blocked start throws nothing,
+ // so check the activity is at least STARTED or the plugin waits out OPEN_TIMEOUT_MS.
+ val activity = foregroundActivity()?.takeIf { it.isStarted() }
+ 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 fun Activity.isStarted(): Boolean =
+ (this as? LifecycleOwner)
+ ?.lifecycle
+ ?.currentState
+ ?.isAtLeast(Lifecycle.State.STARTED) == true
+
+ 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/quickbuild/GradleQuickBuildProvisioner.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt
index 821d86f240..0bb6fa018d 100644
--- a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt
+++ b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt
@@ -441,6 +441,12 @@ class GradleQuickBuildProvisioner(
// No bracket to take, so nothing to suppress; the build still runs.
runBuild()
}
+ // The check above narrows the race but cannot close it: the slot is claimed inside
+ // executeTasks, and a sync that wins it in between is refused here.
+ if (result?.failure == TaskExecutionResult.Failure.BUILD_IN_PROGRESS) {
+ log.info("A Gradle build took the slot first; the Quick Build proxy app build was refused")
+ return ProxyAppBuildResult.SlotBusy
+ }
if (result == null || !result.isSuccessful) {
log.error("Quick-build proxy app build failed: {}", result?.failure)
// The bracket above suppressed the editor's build listener, and result.failure is
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..8145f6248c 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
@@ -39,6 +39,7 @@ import com.itsaky.androidide.eventbus.events.BuildStartedEvent
import com.itsaky.androidide.lookup.Lookup
import com.itsaky.androidide.lsp.java.debug.JdwpOptions
import com.itsaky.androidide.managers.ToolsManager
+import com.itsaky.androidide.plugins.manager.services.IdeBuildServiceImpl
import com.itsaky.androidide.preferences.internal.BuildPreferences
import com.itsaky.androidide.preferences.internal.DevOpsPreferences
import com.itsaky.androidide.projects.ProjectManagerImpl
@@ -67,6 +68,7 @@ import com.itsaky.androidide.tooling.api.messages.result.BuildResult
import com.itsaky.androidide.tooling.api.messages.result.GradleWrapperCheckResult
import com.itsaky.androidide.tooling.api.messages.result.InitializeResult
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult
+import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.BUILD_IN_PROGRESS
import com.itsaky.androidide.tooling.api.models.ToolingServerMetadata
import com.itsaky.androidide.tooling.events.ProgressEvent
import com.itsaky.androidide.utils.Environment
@@ -94,6 +96,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,10 +114,15 @@ 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.
+ // 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()
+
+ // Written only while the slot is held, by the build holding it.
@Volatile
- override var isBuildInProgress = false
+ override var currentBuildId: BuildId? = null
private set
/**
@@ -237,8 +245,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
@@ -524,6 +531,8 @@ class GradleBuildService :
// tail is kept anyway: if that build FAILS it is the only copy of Gradle's reason,
// since the tooling API's own failure is a bare enum. See takeInternalBuildOutput.
internalBuildOutput.onLine(line, editorListener(), internalBuild.progressListener)
+ // A plugin's run reads its own lines here, not the pane, which holds whatever ran last.
+ IdeBuildServiceImpl.getInstance().onBuildOutput(line)
}
/**
@@ -817,7 +826,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 +845,11 @@ class GradleBuildService :
override fun executeTasks(message: TaskExecutionMessage): CompletableFuture {
checkServerStarted()
- val future = performBuildTasks(server!!.executeTasks(message))
+ val future =
+ performBuildTasks(refused = TaskExecutionResult(false, BUILD_IN_PROGRESS)) {
+ currentBuildId = message.buildId
+ server!!.executeTasks(message)
+ }
return future.handle { result, exception ->
if (exception != null) {
@@ -856,9 +869,30 @@ class GradleBuildService :
return server!!.cancelCurrentBuild()
}
- private fun performBuildTasks(future: CompletableFuture): CompletableFuture {
+ private fun performBuildTasks(
+ refused: T,
+ dispatch: () -> CompletableFuture,
+ ): CompletableFuture {
+ // Claimed before the request is sent and released only by the build that claimed it, so a
+ // refused request can neither reach the tooling server nor free another build's slot.
+ if (!buildSlot.compareAndSet(false, true)) {
+ logBuildInProgress()
+ // Not null: markBuildAsFinished turns a failed build into null, and callers must tell a
+ // build that never started from one that failed.
+ return CompletableFuture.completedFuture(refused)
+ }
+ val future =
+ try {
+ dispatch()
+ } catch (e: Throwable) {
+ currentBuildId = null
+ buildSlot.set(false)
+ throw e
+ }
+ pendingBuild = future
+
return CompletableFuture
- .runAsync { onPrepareBuildRequest(future) }
+ .runAsync { ensureTmpdir() }
.handleAsync { _, _ ->
try {
return@handleAsync future.get()
@@ -917,17 +951,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 +971,8 @@ class GradleBuildService :
result: T,
throwable: Throwable?,
): T {
- isBuildInProgress = false
+ currentBuildId = null
+ buildSlot.set(false)
return result
}
diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt
index ee9080e593..87487e4420 100644
--- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt
+++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt
@@ -3,6 +3,7 @@ package com.itsaky.androidide.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.itsaky.androidide.activities.editor.QuickBuildClobberConfirmation
+import com.itsaky.androidide.app.BaseApplication
import com.itsaky.androidide.lookup.Lookup
import com.itsaky.androidide.models.ApkMetadata
import com.itsaky.androidide.models.InstallTaskRequest
@@ -13,6 +14,7 @@ import com.itsaky.androidide.projects.api.AndroidModule
import com.itsaky.androidide.projects.builder.BuildService
import com.itsaky.androidide.projects.isPluginProject
import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile
+import com.itsaky.androidide.resources.R
import com.itsaky.androidide.tooling.api.messages.BuildRunType
import com.itsaky.androidide.tooling.api.messages.GradleBuildParams
import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage
@@ -30,6 +32,9 @@ import kotlin.coroutines.cancellation.CancellationException
class BuildViewModel(
private val projectManager: () -> IProjectManager = { IProjectManager.getInstance() },
+ private val buildInProgressMessage: () -> String = {
+ BaseApplication.baseInstance.getString(R.string.build_in_progress_warning)
+ },
) : ViewModel() {
private val log = LoggerFactory.getLogger(BuildViewModel::class.java)
@@ -134,6 +139,10 @@ class BuildViewModel(
reporter.finish(BuildState.Idle)
return@launch
}
+ // Another build claimed the slot after this action's isBuildInProgress check.
+ if (result?.failure == TaskExecutionResult.Failure.BUILD_IN_PROGRESS) {
+ throw RuntimeException(buildInProgressMessage())
+ }
throw RuntimeException("Task execution failed: ${result?.failure}")
}
@@ -202,7 +211,7 @@ class BuildViewModel(
val current = _buildState.value
if (current is BuildState.InProgress) {
log.warn("Build is already in progress. Ignoring new request.")
- onTerminalState?.invoke(BuildState.Error("A build is already in progress."))
+ onTerminalState?.invoke(BuildState.Error(buildInProgressMessage()))
return false
}
if (_buildState.compareAndSet(current, BuildState.InProgress)) return true
@@ -226,6 +235,9 @@ class BuildViewModel(
try {
val result = withContext(Dispatchers.IO) { buildService.executeTasks(tasks) }.await()
if (result == null || !result.isSuccessful) {
+ if (result?.failure == TaskExecutionResult.Failure.BUILD_IN_PROGRESS) {
+ throw RuntimeException(buildInProgressMessage())
+ }
throw RuntimeException("Task execution failed: ${result?.failure}")
}
val apkFile = withContext(Dispatchers.IO) { apkForInstallRequests(tasks) }
diff --git a/app/src/test/java/com/itsaky/androidide/actions/build/SlotBusyBuildActionsTest.kt b/app/src/test/java/com/itsaky/androidide/actions/build/SlotBusyBuildActionsTest.kt
index abb00cc9e0..f5dc38f59d 100644
--- a/app/src/test/java/com/itsaky/androidide/actions/build/SlotBusyBuildActionsTest.kt
+++ b/app/src/test/java/com/itsaky/androidide/actions/build/SlotBusyBuildActionsTest.kt
@@ -30,9 +30,9 @@ import org.robolectric.RobolectricTestRunner
* Run tasks and Sync project while Quick Build's eager prebuild holds the one Gradle slot.
*
* Both actions stay enabled for that internal build, because the user has no build running and a
- * greyed-out item gave no reason. A tap therefore reaches the action, and starting a second build
- * would throw BuildInProgressException, so the tap must flash the slot-busy message and start
- * nothing. Whether the flash is readable and the toolbar repaints is device-only.
+ * greyed-out item gave no reason. A tap therefore reaches the action, and the service would refuse
+ * a second build with a BUILD_IN_PROGRESS result, so the tap must flash the slot-busy message and
+ * start nothing. Whether the flash is readable and the toolbar repaints is device-only.
*/
@RunWith(RobolectricTestRunner::class)
class SlotBusyBuildActionsTest {
diff --git a/app/src/test/java/com/itsaky/androidide/app/PluginTerminalLauncherTest.kt b/app/src/test/java/com/itsaky/androidide/app/PluginTerminalLauncherTest.kt
new file mode 100644
index 0000000000..bcfcd200e9
--- /dev/null
+++ b/app/src/test/java/com/itsaky/androidide/app/PluginTerminalLauncherTest.kt
@@ -0,0 +1,50 @@
+package com.itsaky.androidide.app
+
+import android.app.Application
+import androidx.activity.ComponentActivity
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+/**
+ * The application's foreground activity survives a plain pause, so a non-null activity does not
+ * mean the IDE is on screen. A start from the background is dropped without an exception, so
+ * the launcher has to refuse up front or the plugin waits out the open timeout.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class)
+class PluginTerminalLauncherTest {
+ @Test
+ fun `a backgrounded IDE is refused at once and nothing is started`() {
+ val controller =
+ Robolectric
+ .buildActivity(ComponentActivity::class.java)
+ .setup()
+ .pause()
+ .stop()
+ val activity = controller.get()
+ val results = mutableListOf()
+
+ PluginTerminalLauncher { activity }.launch("true", null, "plugin") { results += it }
+
+ assertThat(results).containsExactly(TerminalCommandResult.NotStarted("Code On the Go is not in the foreground"))
+ assertThat(shadowOf(activity).nextStartedActivity).isNull()
+ }
+
+ @Test
+ fun `an IDE on screen opens the Terminal`() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get()
+ val results = mutableListOf()
+
+ val cancel = PluginTerminalLauncher { activity }.launch("true", null, "plugin") { results += it }
+
+ assertThat(results).isEmpty()
+ assertThat(shadowOf(activity).nextStartedActivity).isNotNull()
+ cancel()
+ }
+}
diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFailureArmsTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFailureArmsTest.kt
index 394d64a117..168ca584de 100644
--- a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFailureArmsTest.kt
+++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFailureArmsTest.kt
@@ -85,6 +85,20 @@ class GradleQuickBuildProvisionerFailureArmsTest {
assertThat(gradle.executedTasks).isNull()
}
+ @Test
+ fun `a slot taken after the late check is refused by executeTasks and still reads as a busy slot`() =
+ runTest {
+ // Free on both reads; the sync claims the slot inside executeTasks, which refuses
+ // with BUILD_IN_PROGRESS rather than a Gradle failure.
+ val gradle =
+ FakeBuildService(result = TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_IN_PROGRESS))
+
+ val outcome = testProvisioner(context, projectRoot, buildService = gradle).provision()
+
+ assertThat(failureText(outcome)).isEqualTo(context.getString(R.string.quick_build_slot_busy))
+ assertThat(gradle.executedTasks).isEqualTo(listOf(":app:assembleDebug"))
+ }
+
@Test
fun `a tap during the Gradle sync is told the sync is still running, not that the build failed`() =
runTest {
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..24f6776118
--- /dev/null
+++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildServiceSlotTest.kt
@@ -0,0 +1,97 @@
+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.BuildRunType
+import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage
+import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult
+import com.itsaky.androidide.utils.Environment
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import java.io.ByteArrayInputStream
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.TimeUnit
+
+/**
+ * The Gradle build slot when a second build is requested while one runs.
+ *
+ * The defects this pins: the slot was checked and taken on a pool thread after the request had
+ * already been sent, so two callers could both reach the tooling server; and a refused request
+ * still cleared the slot when it completed, while the first build was running.
+ */
+@RunWith(RobolectricTestRunner::class)
+class GradleBuildServiceSlotTest {
+ @get:Rule
+ val tmp = TemporaryFolder()
+
+ private lateinit var service: GradleBuildService
+ private val rpc = CompletableFuture()
+
+ // A second request that reaches the server is refused there, as ToolingApiServerImpl does.
+ private val server =
+ mockk {
+ every { executeTasks(any()) } returnsMany
+ listOf(rpc, CompletableFuture.failedFuture(IllegalStateException("Build is already in progress")))
+ }
+
+ @Before
+ fun setUp() {
+ Environment.TMP_DIR = tmp.newFolder("tmp")
+ service = Robolectric.buildService(GradleBuildService::class.java).get()
+ service.onListenerStarted(server, ByteArrayInputStream(ByteArray(0)))
+ }
+
+ @Test
+ fun `a second build is refused without reaching the tooling server`() {
+ service.executeTasks(listOf(":app:assembleDebug"))
+ val second = service.executeTasks(listOf(":app:test"))
+
+ // Distinct from a failed build, which completes as null.
+ assertThat(second.get(5, TimeUnit.SECONDS))
+ .isEqualTo(TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_IN_PROGRESS))
+ verify(exactly = 1) { server.executeTasks(any()) }
+ }
+
+ @Test
+ fun `a refused build leaves the running build's slot taken`() {
+ val first = service.executeTasks(listOf(":app:assembleDebug"))
+ service.executeTasks(listOf(":app:test")).get(5, TimeUnit.SECONDS)
+
+ assertThat(service.isBuildInProgress).isTrue()
+
+ rpc.complete(TaskExecutionResult(isSuccessful = true, failure = null))
+ first.get(5, TimeUnit.SECONDS)
+ assertThat(service.isBuildInProgress).isFalse()
+ }
+
+ @Test
+ fun `a request that fails to send frees the slot`() {
+ every { server.executeTasks(any()) } throws IllegalStateException("closed")
+
+ runCatching { service.executeTasks(listOf(":app:assembleDebug")) }
+
+ assertThat(service.isBuildInProgress).isFalse()
+ }
+
+ @Test
+ fun `the current build id belongs to the build holding the slot`() {
+ val message = TaskExecutionMessage(tasks = listOf(":app:test"), buildId = service.nextBuildId(BuildRunType.TaskRun))
+ val first = service.executeTasks(message)
+ // A refused second build must not take over the id.
+ service.executeTasks(listOf(":app:assembleDebug")).get(5, TimeUnit.SECONDS)
+
+ assertThat(service.currentBuildId).isEqualTo(message.buildId)
+
+ rpc.complete(TaskExecutionResult(isSuccessful = true, failure = null))
+ first.get(5, TimeUnit.SECONDS)
+ assertThat(service.currentBuildId).isNull()
+ }
+}
diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt
index c4397effc2..f57c19391f 100644
--- a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt
+++ b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt
@@ -56,7 +56,7 @@ class BuildViewModelTest {
@Test
fun `givenAQueuedBuild_whenASecondRequestArrivesBeforeItRuns_thenTheSecondIsRejected`() {
- val viewModel = BuildViewModel()
+ val viewModel = BuildViewModel(buildInProgressMessage = { IN_PROGRESS })
val outcomes = mutableListOf()
// Neither launched block has run, so the second call sees exactly what a second thread
@@ -64,12 +64,12 @@ class BuildViewModelTest {
viewModel.runQuickBuild(module, variant, launchInDebugMode = false) { outcomes += it }
viewModel.runQuickBuild(module, variant, launchInDebugMode = false) { outcomes += it }
- assertThat(outcomes).containsExactly(BuildState.Error("A build is already in progress."))
+ assertThat(outcomes).containsExactly(BuildState.Error(IN_PROGRESS))
}
@Test
fun `givenNoBuild_whenRequestingOne_thenTheStateIsClaimedBeforeTheCoroutineRuns`() {
- val viewModel = BuildViewModel()
+ val viewModel = BuildViewModel(buildInProgressMessage = { IN_PROGRESS })
viewModel.runQuickBuild(module, variant, launchInDebugMode = false)
@@ -78,19 +78,19 @@ class BuildViewModelTest {
@Test
fun `givenAQueuedBuild_whenTasksAreRequested_thenTheRequestIsRefusedAndReported`() {
- val viewModel = BuildViewModel()
+ val viewModel = BuildViewModel(buildInProgressMessage = { IN_PROGRESS })
val outcomes = mutableListOf()
viewModel.runQuickBuild(module, variant, launchInDebugMode = false)
val accepted = viewModel.runTasks(listOf(":app:installDebug")) { outcomes += it }
assertThat(accepted).isFalse()
- assertThat(outcomes).containsExactly(BuildState.Error("A build is already in progress."))
+ assertThat(outcomes).containsExactly(BuildState.Error(IN_PROGRESS))
}
@Test
fun `givenNoBuild_whenTasksAreRequested_thenTheSlotIsClaimedBeforeTheCoroutineRuns`() {
- val viewModel = BuildViewModel()
+ val viewModel = BuildViewModel(buildInProgressMessage = { IN_PROGRESS })
val accepted = viewModel.runTasks(listOf(":app:installDebug"))
@@ -100,7 +100,7 @@ class BuildViewModelTest {
@Test
fun `givenARunTapsClobberAnswer_whenTasksRunNext_thenTheInstallDoesNotInheritIt`() {
- val viewModel = BuildViewModel()
+ val viewModel = BuildViewModel(buildInProgressMessage = { IN_PROGRESS })
viewModel.runQuickBuild(
module,
variant,
@@ -128,7 +128,7 @@ class BuildViewModelTest {
every { variantList } returns listOf(debug)
}
val projectManager = mockk { every { getAndroidAppModules() } returns listOf(app) }
- val viewModel = BuildViewModel { projectManager }
+ val viewModel = BuildViewModel(projectManager = { projectManager })
assertThat(viewModel.installsAnAppVariant(listOf(":app:installDebug"))).isTrue()
assertThat(viewModel.installsAnAppVariant(listOf("installDebug", ":app:assembleDebug"))).isTrue()
@@ -145,7 +145,7 @@ class BuildViewModelTest {
}
Lookup.getDefault().register(BuildService.KEY_BUILD_SERVICE, buildService)
try {
- val viewModel = BuildViewModel()
+ val viewModel = BuildViewModel(buildInProgressMessage = { IN_PROGRESS })
val outcomes = mutableListOf()
viewModel.runTasks(listOf(":app:installDebug")) { outcomes += it }
@@ -159,7 +159,7 @@ class BuildViewModelTest {
@Test
fun `givenNoBuildService_whenTasksRun_thenTheRunEndsInAnErrorReportedOnce`() {
- val viewModel = BuildViewModel()
+ val viewModel = BuildViewModel(buildInProgressMessage = { IN_PROGRESS })
val outcomes = mutableListOf()
viewModel.runTasks(listOf(":app:installDebug")) { outcomes += it }
@@ -168,4 +168,29 @@ class BuildViewModelTest {
assertThat(outcomes).containsExactly(BuildState.Error("Build service not found."))
assertThat(viewModel.buildState.value).isEqualTo(BuildState.Error("Build service not found."))
}
+
+ @Test
+ fun `givenTheSlotTakenAfterTheGuard_whenTasksRun_thenTheRefusalReadsAsABusySlot`() {
+ val buildService =
+ mockk {
+ every { executeTasks(any>()) } returns
+ CompletableFuture.completedFuture(TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_IN_PROGRESS))
+ }
+ Lookup.getDefault().register(BuildService.KEY_BUILD_SERVICE, buildService)
+ try {
+ val viewModel = BuildViewModel(buildInProgressMessage = { IN_PROGRESS })
+ val outcomes = mutableListOf()
+
+ viewModel.runTasks(listOf(":app:installDebug")) { outcomes += it }
+ awaitOutcome(outcomes)
+
+ assertThat(outcomes).containsExactly(BuildState.Error(IN_PROGRESS))
+ } finally {
+ Lookup.getDefault().unregister(BuildService.KEY_BUILD_SERVICE)
+ }
+ }
+
+ private companion object {
+ const val IN_PROGRESS = "A build is already in progress."
+ }
}
diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md
index 7a9f73b90d..8534f8d5bc 100644
--- a/docs/PLUGIN_API_CHANGELOG.md
+++ b/docs/PLUGIN_API_CHANGELOG.md
@@ -36,6 +36,48 @@ 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, a task name starting with `-`), or `Cancelled`.
+ `IdeBuildService.cancelBuild()` cancels the running build, whoever started it. Non-empty
+ `arguments` and `cancelBuild()` need `system.commands` and throw `SecurityException` without
+ it. While the build runs, a sync or build the user starts is refused. Floor
+ `plugin.min_ide_version` at `26.41`: an older IDE has neither method.
+- **added — List the project's Gradle tasks** _(ADFA-6373)_ **[verified]**
+ `IdeBuildService.getTasks()` returns the open project's tasks, root project and modules alike,
+ as `GradleTaskInfo(path, name, projectPath, group, description)`. It reflects the last sync:
+ empty before one or with no project open, and a task added since appears after the next.
+ Needs no permission. Floor `plugin.min_ide_version` at `26.41`: an older IDE has no such
+ method or class.
+- **breaking — `IdeBuildService.executeTasks(vararg String)` needs at least one task** _(ADFA-6373)_
+ With no tasks it used to run the project's default tasks; it now completes with `false`
+ without building. Name the tasks to run. A task name starting with `-` still completes with
+ `false`, now without starting a build.
+- **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, and while its task runs, a sync or build the user starts is refused: keep
+ `timeoutMs` no longer than the task needs. A cancel or timeout completes once Gradle stops.
+- **breaking — `CommandSpec.ShellCommand.workingDirectory` needs an open project** _(ADFA-6373)_
+ With no project open, a `workingDirectory` used to be accepted unchecked; `executeCommand` now
+ throws `SecurityException`, as it does for one outside the project. Pass null to run in the
+ default directory.
- **added — Plugin languages: tree-sitter highlighting and a language server** _(ADFA-4851)_ **[verified]**
A plugin implementing `LanguageExtension` returns `LanguageDefinition`s, each claiming file
extensions and optionally carrying a `TreeSitterGrammar` and a `LanguageServerDefinition`.
diff --git a/docs/plugin-api.md b/docs/plugin-api.md
index 9b1a83256e..2ee7f69918 100644
--- a/docs/plugin-api.md
+++ b/docs/plugin-api.md
@@ -11,7 +11,7 @@ The surface a plugin binds to is broader than one module. All of the following a
- **The `:plugin-api` module** — package `com.itsaky.androidide.plugins.*`:
- Core: `IPlugin` (lifecycle), `PluginContext`, `PluginLogger`, `ServiceRegistry`, `ResourceManager`.
- Extension interfaces plugins **implement**: `UIExtension`, `EditorExtension`, `EditorTabExtension`, `DocumentationExtension`, `BuildActionExtension`, `SnippetExtension`, `ProjectExtension`, `FileOpenExtension`, `SettingsExtension`, `LanguageExtension`.
- - IDE service interfaces plugins **call** (via `ServiceRegistry.get(X::class.java)`): `IdeProjectService`, `IdeEditorService`, `IdeFileService`, `IdeEnvironmentService`, `IdeArchiveService`, `IdeBuildService`, `IdeLogService`, `IdeUIService`, `IdeEditorTabService`, `IdeTooltipService`, `IdeThemeService`, `IdeFeatureFlagService`, `IdeCommandService`, `IdeTemplateService`, `IdeSnippetService`, `IdeSidebarService`.
+ - IDE service interfaces plugins **call** (via `ServiceRegistry.get(X::class.java)`): `IdeProjectService`, `IdeEditorService`, `IdeFileService`, `IdeEnvironmentService`, `IdeArchiveService`, `IdeBuildService`, `IdeLogService`, `IdeUIService`, `IdeEditorTabService`, `IdeTooltipService`, `IdeThemeService`, `IdeFeatureFlagService`, `IdeCommandService`, `IdeTerminalService`, `IdeTemplateService`, `IdeSnippetService`, `IdeSidebarService`.
- Cross-plugin service interfaces, where **one plugin implements what another calls** (via `SharedServices`): `LlmInferenceService` — implemented by ai-core, called by every AI plugin — together with the types nested in it that a *backend* plugin implements (`LlmBackend`, `HistoryCapableBackend`, `ToolCallingBackend`, `CancellableBackend`, `ConfigurableBackend`, `EmbeddingBackend`) and the value types either side constructs (`ChatMessage`, `LlmConfig`, `LlmResponse`, `SystemPromptRequest`, `ToolDefinition`, `ToolCallRequest`). Also `ToolSourceRegistry` — implemented by ai-core, called by any plugin contributing tools to the agent — with `ToolSource` and `ToolSpec`, which a *contributing* plugin implements, `ToolInvocation`, which ai-core constructs and passes to `ToolSource.invoke`, and `ToolOutcome`, which the source returns.
- Utility classes plugins **instantiate**: `KeystoreSecretStore` (AES/GCM over the Android Keystore, alias supplied by the caller); the AI prompt config engine in `ai.prompt` (`PromptTemplateEngine`, `PromptConfigLoader`, `PromptConfigStore`, `PromptConfigDocument`, `PromptConfigObject`, `AssetPromptConfigSource`), generic over a plugin's own config type through `PromptConfigParser` and `PromptConfigProvider`, which a plugin implements; and the settings-pane helpers in `ai.ui` (`SecretRevealController` with `RevealToggle`, `applyPaneStyling` with `PaneStyle`, `ButtonColors` and `FieldColors`). Host-side implementations rather than interfaces, so plugins share one copy in the process instead of compiling their own.
- Data classes plugins **construct** (e.g. `MenuItem`, `TabItem`, `EditorTabItem`, `NavigationItem`, `ToolbarAction`, `FabAction`, `PluginBuildAction`, `SnippetContribution`, `PluginTooltipEntry`, `PluginSettingsEntry`, `LanguageDefinition`, `TreeSitterGrammar`, `LanguageServerDefinition`).
diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api
index adaf15aa4f..9ac8d0a975 100644
--- a/plugin-api/api/plugin-api.api
+++ b/plugin-api/api/plugin-api.api
@@ -1610,6 +1610,64 @@ public abstract interface class com/itsaky/androidide/plugins/services/GradleSyn
public abstract fun onComplete (ZLjava/lang/String;)V
}
+public final class com/itsaky/androidide/plugins/services/GradleTaskInfo {
+ public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
+ public final fun component1 ()Ljava/lang/String;
+ public final fun component2 ()Ljava/lang/String;
+ public final fun component3 ()Ljava/lang/String;
+ public final fun component4 ()Ljava/lang/String;
+ public final fun component5 ()Ljava/lang/String;
+ public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/GradleTaskInfo;
+ public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/GradleTaskInfo;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/GradleTaskInfo;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getDescription ()Ljava/lang/String;
+ public final fun getGroup ()Ljava/lang/String;
+ public final fun getName ()Ljava/lang/String;
+ public final fun getPath ()Ljava/lang/String;
+ public final fun getProjectPath ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public abstract class com/itsaky/androidide/plugins/services/GradleTaskResult {
+}
+
+public final class com/itsaky/androidide/plugins/services/GradleTaskResult$Cancelled : com/itsaky/androidide/plugins/services/GradleTaskResult {
+ public static final field INSTANCE Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Cancelled;
+ public fun equals (Ljava/lang/Object;)Z
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/itsaky/androidide/plugins/services/GradleTaskResult$Failed : com/itsaky/androidide/plugins/services/GradleTaskResult {
+ public fun (Ljava/lang/String;)V
+ public final fun component1 ()Ljava/lang/String;
+ public final fun copy (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Failed;
+ public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Failed;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Failed;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getReason ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/itsaky/androidide/plugins/services/GradleTaskResult$Refused : com/itsaky/androidide/plugins/services/GradleTaskResult {
+ public fun (Ljava/lang/String;)V
+ public final fun component1 ()Ljava/lang/String;
+ public final fun copy (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Refused;
+ public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Refused;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Refused;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getReason ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/itsaky/androidide/plugins/services/GradleTaskResult$Success : com/itsaky/androidide/plugins/services/GradleTaskResult {
+ public static final field INSTANCE Lcom/itsaky/androidide/plugins/services/GradleTaskResult$Success;
+ public fun equals (Ljava/lang/Object;)Z
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
public abstract interface class com/itsaky/androidide/plugins/services/IdeArchiveService {
public abstract fun extract (Ljava/io/InputStream;Lcom/itsaky/androidide/plugins/services/ArchiveFormat;Ljava/io/File;Lkotlin/jvm/functions/Function2;)Lcom/itsaky/androidide/plugins/services/ExtractResult;
}
@@ -1620,8 +1678,11 @@ public final class com/itsaky/androidide/plugins/services/IdeArchiveService$Defa
public abstract interface class com/itsaky/androidide/plugins/services/IdeBuildService {
public abstract fun addBuildStatusListener (Lcom/itsaky/androidide/plugins/services/BuildStatusListener;)V
+ public abstract fun cancelBuild ()Ljava/util/concurrent/CompletableFuture;
+ public abstract fun executeTasks (Ljava/util/List;Ljava/util/List;)Ljava/util/concurrent/CompletableFuture;
public abstract fun executeTasks ([Ljava/lang/String;)Ljava/util/concurrent/CompletableFuture;
public abstract fun getBuildOutput ()Ljava/lang/String;
+ public abstract fun getTasks ()Ljava/util/List;
public abstract fun isBuildInProgress ()Z
public abstract fun isToolingServerStarted ()Z
public abstract fun removeBuildStatusListener (Lcom/itsaky/androidide/plugins/services/BuildStatusListener;)V
@@ -1630,8 +1691,12 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeBuildS
}
public final class com/itsaky/androidide/plugins/services/IdeBuildService$DefaultImpls {
+ public static fun cancelBuild (Lcom/itsaky/androidide/plugins/services/IdeBuildService;)Ljava/util/concurrent/CompletableFuture;
+ public static fun executeTasks (Lcom/itsaky/androidide/plugins/services/IdeBuildService;Ljava/util/List;Ljava/util/List;)Ljava/util/concurrent/CompletableFuture;
public static fun executeTasks (Lcom/itsaky/androidide/plugins/services/IdeBuildService;[Ljava/lang/String;)Ljava/util/concurrent/CompletableFuture;
+ public static synthetic fun executeTasks$default (Lcom/itsaky/androidide/plugins/services/IdeBuildService;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Ljava/util/concurrent/CompletableFuture;
public static fun getBuildOutput (Lcom/itsaky/androidide/plugins/services/IdeBuildService;)Ljava/lang/String;
+ public static fun getTasks (Lcom/itsaky/androidide/plugins/services/IdeBuildService;)Ljava/util/List;
public static fun runApp (Lcom/itsaky/androidide/plugins/services/IdeBuildService;Lcom/itsaky/androidide/plugins/services/BuildAndLaunchCallback;)V
public static fun triggerGradleSync (Lcom/itsaky/androidide/plugins/services/IdeBuildService;Lcom/itsaky/androidide/plugins/services/GradleSyncCallback;)V
}
@@ -1783,6 +1848,15 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeTempla
public abstract fun unregisterTemplate (Ljava/lang/String;)Z
}
+public abstract interface class com/itsaky/androidide/plugins/services/IdeTerminalService {
+ public abstract fun isTerminalReady (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public abstract fun runInTerminal (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class com/itsaky/androidide/plugins/services/IdeTerminalService$DefaultImpls {
+ public static synthetic fun runInTerminal$default (Lcom/itsaky/androidide/plugins/services/IdeTerminalService;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+}
+
public abstract interface class com/itsaky/androidide/plugins/services/IdeThemeService {
public abstract fun addThemeChangeListener (Lcom/itsaky/androidide/plugins/services/ThemeChangeListener;)V
public abstract fun isDarkMode ()Z
@@ -2094,6 +2168,33 @@ public final class com/itsaky/androidide/plugins/services/SharedServices {
public final fun unregister (Ljava/lang/Class;)V
}
+public abstract class com/itsaky/androidide/plugins/services/TerminalCommandResult {
+}
+
+public final class com/itsaky/androidide/plugins/services/TerminalCommandResult$Completed : com/itsaky/androidide/plugins/services/TerminalCommandResult {
+ public fun (ILjava/lang/String;)V
+ public final fun component1 ()I
+ public final fun component2 ()Ljava/lang/String;
+ public final fun copy (ILjava/lang/String;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Completed;
+ public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Completed;ILjava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Completed;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getExitCode ()I
+ public final fun getOutput ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/itsaky/androidide/plugins/services/TerminalCommandResult$NotStarted : com/itsaky/androidide/plugins/services/TerminalCommandResult {
+ public fun (Ljava/lang/String;)V
+ public final fun component1 ()Ljava/lang/String;
+ public final fun copy (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$NotStarted;
+ public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$NotStarted;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$NotStarted;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getReason ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
public abstract interface class com/itsaky/androidide/plugins/services/ThemeChangeListener {
public abstract fun onThemeChanged (Z)V
}
diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt
index e4350a5978..d38bdceb68 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,139 @@ package com.itsaky.androidide.plugins.extensions
import com.itsaky.androidide.plugins.IPlugin
interface BuildActionExtension : IPlugin {
- fun getBuildActions(): List
- fun toolbarActionsToHide(): Set = emptySet()
- fun onActionStarted(actionId: String) {}
- fun onActionCompleted(actionId: String, result: CommandResult) {}
+ fun getBuildActions(): List
+
+ fun toolbarActionsToHide(): Set = emptySet()
+
+ fun onActionStarted(actionId: String) {}
+
+ fun onActionCompleted(
+ actionId: String,
+ result: CommandResult,
+ ) {}
}
object ToolbarActionIds {
- const val QUICK_RUN = "ide.editor.build.quickRun"
- const val PROJECT_SYNC = "ide.editor.syncProject"
- const val DEBUG = "ide.editor.build.debug"
- const val RUN_TASKS = "ide.editor.build.runTasks"
- const val UNDO = "ide.editor.code.text.undo"
- const val REDO = "ide.editor.code.text.redo"
- const val SAVE = "ide.editor.files.saveAll"
- const val PREVIEW_LAYOUT = "ide.editor.previewLayout"
- const val FIND = "ide.editor.find"
- const val FIND_IN_FILE = "ide.editor.find.inFile"
- const val FIND_IN_PROJECT = "ide.editor.find.inProject"
- const val LAUNCH_APP = "ide.editor.launchInstalledApp"
- const val DISCONNECT_LOG_SENDERS = "ide.editor.service.logreceiver.disconnectSenders"
- const val GENERATE_XML = "ide.editor.generatexml"
-
- val ALL: Set = setOf(
- QUICK_RUN, PROJECT_SYNC, DEBUG, RUN_TASKS,
- UNDO, REDO, SAVE, PREVIEW_LAYOUT,
- FIND, FIND_IN_FILE, FIND_IN_PROJECT,
- LAUNCH_APP, DISCONNECT_LOG_SENDERS, GENERATE_XML
- )
-
- val BUILD_HIDEABLE: Set = setOf(
- QUICK_RUN, PROJECT_SYNC, DEBUG, RUN_TASKS, LAUNCH_APP
- )
+ const val QUICK_RUN = "ide.editor.build.quickRun"
+ const val PROJECT_SYNC = "ide.editor.syncProject"
+ const val DEBUG = "ide.editor.build.debug"
+ const val RUN_TASKS = "ide.editor.build.runTasks"
+ const val UNDO = "ide.editor.code.text.undo"
+ const val REDO = "ide.editor.code.text.redo"
+ const val SAVE = "ide.editor.files.saveAll"
+ const val PREVIEW_LAYOUT = "ide.editor.previewLayout"
+ const val FIND = "ide.editor.find"
+ const val FIND_IN_FILE = "ide.editor.find.inFile"
+ const val FIND_IN_PROJECT = "ide.editor.find.inProject"
+ const val LAUNCH_APP = "ide.editor.launchInstalledApp"
+ const val DISCONNECT_LOG_SENDERS = "ide.editor.service.logreceiver.disconnectSenders"
+ const val GENERATE_XML = "ide.editor.generatexml"
+
+ val ALL: Set =
+ setOf(
+ QUICK_RUN,
+ PROJECT_SYNC,
+ DEBUG,
+ RUN_TASKS,
+ UNDO,
+ REDO,
+ SAVE,
+ PREVIEW_LAYOUT,
+ FIND,
+ FIND_IN_FILE,
+ FIND_IN_PROJECT,
+ LAUNCH_APP,
+ DISCONNECT_LOG_SENDERS,
+ GENERATE_XML,
+ )
+
+ val BUILD_HIDEABLE: Set =
+ setOf(
+ QUICK_RUN,
+ PROJECT_SYNC,
+ DEBUG,
+ RUN_TASKS,
+ LAUNCH_APP,
+ )
}
data class PluginBuildAction(
- val id: String,
- val name: String,
- val description: String,
- val icon: Int? = null,
- val category: BuildActionCategory = BuildActionCategory.CUSTOM,
- val command: CommandSpec,
- val timeoutMs: Long = 600_000
+ val id: String,
+ val name: String,
+ val description: String,
+ val icon: Int? = null,
+ val category: BuildActionCategory = BuildActionCategory.CUSTOM,
+ val command: CommandSpec,
+ val timeoutMs: Long = 600_000,
)
sealed class CommandSpec {
- data class ShellCommand(
- val executable: String,
- val arguments: List = emptyList(),
- val workingDirectory: String? = null,
- val environment: Map = emptyMap()
- ) : CommandSpec()
-
- data class GradleTask(
- val taskPath: String,
- val arguments: List = emptyList()
- ) : CommandSpec()
+ /**
+ * Runs [executable] directly, without a shell. [workingDirectory] is absolute or relative to
+ * the project root, and must lie inside it; null means the project root. Giving one with no
+ * project open throws [SecurityException].
+ */
+ data class ShellCommand(
+ val executable: String,
+ val arguments: List = emptyList(),
+ val workingDirectory: String? = null,
+ val environment: Map = emptyMap(),
+ ) : CommandSpec()
+
+ /**
+ * Runs [taskPath] with Gradle [arguments] through the IDE's tooling server, as
+ * [com.itsaky.androidide.plugins.services.IdeBuildService.executeTasks] does; it never starts
+ * a second Gradle daemon. Output goes to the Build Output pane and arrives as one
+ * [CommandOutput.StdOut] per line once the build ends; it is not streamed. Exit code 0 means
+ * success, 1 a failed build. A refused build (another one running, tooling server down)
+ * fails with exit code -1 and the reason in [CommandResult.Failure.error].
+ *
+ * The IDE runs one build at a time, so while the task runs a sync or build the user starts is
+ * refused. Keep the timeout no longer than the task needs. A cancel or timeout asks Gradle to
+ * stop and completes once it has.
+ *
+ * Before 26.41 this ran `./gradlew` as a separate process.
+ */
+ data class GradleTask(
+ val taskPath: String,
+ val arguments: List = emptyList(),
+ ) : CommandSpec()
}
sealed class CommandOutput {
- data class StdOut(val line: String) : CommandOutput()
- data class StdErr(val line: String) : CommandOutput()
- data class ExitCode(val code: Int) : CommandOutput()
+ data class StdOut(
+ val line: String,
+ ) : CommandOutput()
+
+ data class StdErr(
+ val line: String,
+ ) : CommandOutput()
+
+ data class ExitCode(
+ val code: Int,
+ ) : CommandOutput()
}
sealed class CommandResult {
- data class Success(
- val exitCode: Int,
- val stdout: String,
- val stderr: String,
- val durationMs: Long
- ) : CommandResult()
-
- data class Failure(
- val exitCode: Int,
- val stdout: String,
- val stderr: String,
- val error: String?,
- val durationMs: Long
- ) : CommandResult()
-
- data class Cancelled(
- val partialStdout: String,
- val partialStderr: String
- ) : CommandResult()
+ data class Success(
+ val exitCode: Int,
+ val stdout: String,
+ val stderr: String,
+ val durationMs: Long,
+ ) : CommandResult()
+
+ data class Failure(
+ val exitCode: Int,
+ val stdout: String,
+ val stderr: String,
+ val error: String?,
+ val durationMs: Long,
+ ) : CommandResult()
+
+ data class Cancelled(
+ val partialStdout: String,
+ val partialStderr: String,
+ ) : CommandResult()
}
-enum class BuildActionCategory { BUILD, TEST, DEPLOY, LINT, CUSTOM }
\ No newline at end of file
+enum class BuildActionCategory { BUILD, TEST, DEPLOY, LINT, CUSTOM }
diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt
index 82d08ddbc9..77be295d79 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
@@ -386,13 +386,49 @@ interface IdeBuildService {
/**
* Executes the given Gradle task paths (e.g. ":app:assembleDebug") and completes with
- * true on success, false on failure/cancellation.
+ * true on success, false on failure/cancellation. Since 26.41, no tasks, or a task name
+ * starting with `-`, completes with false without building.
*
* Default completes with false so this addition is binary-compatible: hosts that predate
* the method, and any implementor that does not override it, report "not executed".
*/
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, the tooling server has not
+ * started, or a task name starts with `-`, the future completes with [GradleTaskResult.Refused]
+ * at once and no build starts. While this build runs, a sync or build the user starts is
+ * refused.
+ *
+ * Non-empty [arguments] need the `system.commands` permission (`--init-script` runs any code
+ * in the IDE's Gradle daemon); without it this throws [SecurityException].
+ *
+ * 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].
+ * Needs the `system.commands` permission; without it this throws [SecurityException].
+ *
+ * Floor `plugin.min_ide_version` at 26.41 to use it (see [executeTasks]).
+ */
+ fun cancelBuild(): CompletableFuture = CompletableFuture.completedFuture(false)
+
/**
* Builds and runs the app on the connected device.
* @param callback The callback to be invoked when the operation completes
@@ -414,6 +450,54 @@ interface IdeBuildService {
* @return The build output as a string, or null if no build output is available
*/
fun getBuildOutput(): String? = null
+
+ /**
+ * Lists the Gradle tasks of the open project, root project and modules alike, as Gradle
+ * reported them at the last sync. Empty when no project is open or it has not synced. A task
+ * added to a build script since then appears only after the next sync.
+ *
+ * Floor `plugin.min_ide_version` at 26.41 to use it (see [executeTasks]).
+ */
+ fun getTasks(): List = emptyList()
+}
+
+/**
+ * A Gradle task of the open project, as listed by [IdeBuildService.getTasks].
+ *
+ * @param path The task path to run it by, e.g. ":app:testDebugUnitTest".
+ * @param projectPath The path of the project that owns the task, e.g. ":app" or ":" for the root.
+ * @param group The task's group, e.g. "verification"; null for a task with no group.
+ */
+data class GradleTaskInfo(
+ val path: String,
+ val name: String,
+ val projectPath: String,
+ val group: String?,
+ val description: String?,
+)
+
+/**
+ * Outcome of [IdeBuildService.executeTasks] with Gradle arguments.
+ */
+sealed class GradleTaskResult {
+ /** The build ran and succeeded. */
+ data object Success : GradleTaskResult()
+
+ /**
+ * The build ran and failed. [reason] names the failure (e.g. "BUILD_FAILED"); the log is in
+ * [IdeBuildService.getBuildOutput].
+ */
+ data class Failed(
+ val reason: String,
+ ) : GradleTaskResult()
+
+ /** The build never started. [reason] says why, e.g. another build is in progress. */
+ data class Refused(
+ val reason: String,
+ ) : GradleTaskResult()
+
+ /** The build started and was cancelled, by [IdeBuildService.cancelBuild] or by the user. */
+ data object Cancelled : GradleTaskResult()
}
/**
diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeTerminalService.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeTerminalService.kt
new file mode 100644
index 0000000000..c216a3f4be
--- /dev/null
+++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeTerminalService.kt
@@ -0,0 +1,51 @@
+package com.itsaky.androidide.plugins.services
+
+/**
+ * Service interface for the IDE's terminal environment (bash and the packages installed with it).
+ *
+ * Use [IdeCommandService] to run a command headless; use [runInTerminal] when the user should see
+ * what ran. Available since 26.41.
+ */
+interface IdeTerminalService {
+ /**
+ * True when the terminal environment is installed and bash starts and exits cleanly. Starts a
+ * process, so call it off the main thread. Needs no permission.
+ */
+ suspend fun isTerminalReady(): Boolean
+
+ /**
+ * Opens a new session in the visible Terminal, runs [command] there with bash, and suspends
+ * until it exits. The session stays open afterwards so the user can read what ran.
+ *
+ * [workingDirectory] is absolute or relative to the project root, and must lie inside it;
+ * null means the project root. Cancelling the calling coroutine kills the command.
+ *
+ * Requires the SYSTEM_COMMANDS permission.
+ *
+ * @throws SecurityException if the plugin lacks SYSTEM_COMMANDS, or [workingDirectory] is
+ * outside the project root or given with no project open.
+ */
+ suspend fun runInTerminal(
+ command: String,
+ workingDirectory: String? = null,
+ ): TerminalCommandResult
+}
+
+/**
+ * Outcome of [IdeTerminalService.runInTerminal].
+ */
+sealed class TerminalCommandResult {
+ /**
+ * The command ran and exited with [exitCode]. [output] is the session transcript: the echoed
+ * command line followed by stdout and stderr interleaved as the terminal showed them.
+ */
+ data class Completed(
+ val exitCode: Int,
+ val output: String,
+ ) : TerminalCommandResult()
+
+ /** The command never ran. [reason] says why, e.g. the terminal environment is not installed. */
+ data class NotStarted(
+ val reason: String,
+ ) : TerminalCommandResult()
+}
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt
index 46264abdf4..37b9095de6 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
@@ -43,7 +43,6 @@ import com.itsaky.androidide.plugins.manager.project.PluginProjectManager
import com.itsaky.androidide.plugins.manager.security.PluginSecurityManager
import com.itsaky.androidide.plugins.manager.services.CogoProjectProvider
import com.itsaky.androidide.plugins.manager.services.IdeArchiveServiceImpl
-import com.itsaky.androidide.plugins.manager.services.IdeBuildServiceImpl
import com.itsaky.androidide.plugins.manager.services.IdeCommandServiceImpl
import com.itsaky.androidide.plugins.manager.services.IdeEditorServiceImpl
import com.itsaky.androidide.plugins.manager.services.IdeEditorTabServiceImpl
@@ -56,9 +55,11 @@ 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
+import com.itsaky.androidide.plugins.manager.services.PluginBuildService
import com.itsaky.androidide.plugins.manager.snippets.PluginSnippetManager
import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager
import com.itsaky.androidide.plugins.services.IdeArchiveService
@@ -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
@@ -807,6 +809,10 @@ class PluginManager private constructor(
if (commandService is IdeCommandServiceImpl) {
commandService.cancelAllCommands()
}
+ val terminalService = loadedPlugin.context.services.get(IdeTerminalService::class.java)
+ if (terminalService is IdeTerminalServiceImpl) {
+ terminalService.cancelAll()
+ }
val templateService = loadedPlugin.context.services.get(IdeTemplateService::class.java)
if (templateService is IdeTemplateServiceImpl) {
@@ -1126,6 +1132,8 @@ class PluginManager private constructor(
return true
}
+ // The services outlive a disable, which closed the terminal service.
+ (loadedPlugin.context.services.get(IdeTerminalService::class.java) as? IdeTerminalServiceImpl)?.reopen()
loadedPlugin.isEnabled = true
activateLoadedPlugin(loadedPlugin)
return if (loadedPlugin.isEnabled) {
@@ -1194,6 +1202,10 @@ class PluginManager private constructor(
val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java)
if (commandService is IdeCommandServiceImpl) commandService.cancelAllCommands()
}.onFailure { logger.error("Failed to cancel commands for: $pluginId", it) }
+ runCatching {
+ val terminalService = loadedPlugin.context.services.get(IdeTerminalService::class.java)
+ if (terminalService is IdeTerminalServiceImpl) terminalService.cancelAll()
+ }.onFailure { logger.error("Failed to cancel terminal commands for: $pluginId", it) }
runCatching {
val templateService = loadedPlugin.context.services.get(IdeTemplateService::class.java)
if (templateService is IdeTemplateServiceImpl) templateService.cleanupAllTemplates()
@@ -1446,7 +1458,7 @@ class PluginManager private constructor(
pluginId,
"build",
) {
- IdeBuildServiceImpl.getInstance()
+ PluginBuildService(pluginId, permissions)
}
registerServiceWithErrorHandling(
@@ -1627,6 +1639,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)
@@ -1705,7 +1731,7 @@ class PluginManager private constructor(
pluginId,
"build",
) {
- IdeBuildServiceImpl.getInstance()
+ PluginBuildService(pluginId, permissions)
}
registerServiceWithErrorHandling(
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..3a04a1a449
--- /dev/null
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/GradleTaskExecution.kt
@@ -0,0 +1,152 @@
+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 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.atomic.AtomicBoolean
+
+/**
+ * A [CommandSpec.GradleTask] run on the IDE's tooling server. Its output is emitted once the build
+ * ends, from the lines the run captured for itself.
+ *
+ * A cancel or timeout asks Gradle to stop and completes when the build does. If Gradle has not
+ * stopped [stopGraceMs] later, the command completes anyway, though the build may still hold the
+ * slot.
+ */
+internal class GradleTaskExecution(
+ override val executionId: String,
+ private val spec: CommandSpec.GradleTask,
+ private val runner: GradleTaskRunner,
+ private val timeoutMs: Long,
+ private val stopGraceMs: Long = STOP_GRACE_MS,
+) : RunningCommand {
+ private val outputChannel = Channel(capacity = Channel.UNLIMITED)
+ private val resultDeferred = CompletableDeferred()
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+ private val finished = AtomicBoolean(false)
+ private var startTime = 0L
+
+ @Volatile
+ private var timedOut = false
+
+ @Volatile
+ private var run: GradleTaskRun? = null
+
+ @Volatile
+ private var onComplete: () -> Unit = {}
+
+ override val output: Flow = outputChannel.receiveAsFlow()
+
+ override fun start(onComplete: () -> Unit) {
+ this.onComplete = onComplete
+ startTime = System.currentTimeMillis()
+ val run = runner.start(listOf(spec.taskPath), spec.arguments)
+ this.run = run
+ scope.launch {
+ delay(timeoutMs)
+ timedOut = true
+ stop(run) { CommandResult.Failure(-1, it, "", "Gradle task timed out after ${timeoutMs}ms and did not stop", duration()) }
+ }
+ run.result.whenComplete { result, error -> finish(toCommandResult(result, error, run.output())) }
+ }
+
+ private fun toCommandResult(
+ result: GradleTaskResult?,
+ error: Throwable?,
+ output: String,
+ ): CommandResult =
+ when {
+ error != null -> {
+ CommandResult.Failure(-1, output, "", error.message, duration())
+ }
+
+ result == GradleTaskResult.Success -> {
+ CommandResult.Success(0, output, "", duration())
+ }
+
+ result == GradleTaskResult.Cancelled && timedOut -> {
+ CommandResult.Failure(
+ -1,
+ output,
+ "",
+ "Gradle task timed out after ${timeoutMs}ms",
+ duration(),
+ )
+ }
+
+ result == GradleTaskResult.Cancelled -> {
+ CommandResult.Cancelled(output, "")
+ }
+
+ result is GradleTaskResult.Refused -> {
+ CommandResult.Failure(-1, "", "", result.reason, duration())
+ }
+
+ result is GradleTaskResult.Failed -> {
+ CommandResult.Failure(1, output, "", result.reason, duration())
+ }
+
+ else -> {
+ CommandResult.Failure(-1, output, "", "No result", duration())
+ }
+ }
+
+ private fun duration() = System.currentTimeMillis() - startTime
+
+ /** Asks Gradle to stop [run]; completes with [ifNotStopped] if the build outlives the grace period. */
+ private fun stop(
+ run: GradleTaskRun,
+ ifNotStopped: (output: String) -> CommandResult,
+ ) {
+ if (run.result.isDone) return
+ run.cancel()
+ scope.launch {
+ delay(stopGraceMs)
+ finish(ifNotStopped(run.output()))
+ }
+ }
+
+ private fun finish(result: CommandResult) {
+ if (!finished.compareAndSet(false, true)) return
+ scope.cancel()
+ val (stdout, exitCode) =
+ when (result) {
+ is CommandResult.Success -> result.stdout to result.exitCode
+ is CommandResult.Failure -> result.stdout to result.exitCode
+ is CommandResult.Cancelled -> result.partialStdout to null
+ }
+ // Blank lines are kept (they separate stack traces); only the final newline's empty tail goes.
+ if (stdout.isNotEmpty()) {
+ stdout.removeSuffix("\n").lineSequence().forEach { outputChannel.trySend(CommandOutput.StdOut(it)) }
+ }
+ exitCode?.let { outputChannel.trySend(CommandOutput.ExitCode(it)) }
+ outputChannel.close()
+ resultDeferred.complete(result)
+ onComplete()
+ }
+
+ override suspend fun await(): CommandResult = resultDeferred.await()
+
+ override fun cancel() {
+ val run = run ?: return finish(CommandResult.Cancelled("", ""))
+ // Completes with Cancelled once the build stops, so the slot is free by then.
+ stop(run) { CommandResult.Cancelled(it, "") }
+ }
+
+ override fun isRunning(): Boolean = !finished.get()
+
+ companion object {
+ private const val STOP_GRACE_MS = 15_000L
+ }
+}
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/GradleTaskRun.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/GradleTaskRun.kt
new file mode 100644
index 0000000000..c1f2ed2360
--- /dev/null
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/GradleTaskRun.kt
@@ -0,0 +1,40 @@
+package com.itsaky.androidide.plugins.manager.services
+
+import com.itsaky.androidide.plugins.services.GradleTaskResult
+import java.util.concurrent.CompletableFuture
+
+/** One run of Gradle tasks on the tooling server, as [IdeBuildServiceImpl.startTasks] starts it. */
+internal interface GradleTaskRun {
+ val result: CompletableFuture
+
+ /** What Gradle printed for this run, whole once [result] completes. Empty for a refused run. */
+ fun output(): String
+
+ /** Cancels this run's build while it still holds the slot; completes false otherwise. */
+ fun cancel(): CompletableFuture
+}
+
+internal fun interface GradleTaskRunner {
+ fun start(
+ tasks: List,
+ arguments: List,
+ ): GradleTaskRun
+}
+
+/** The last [maxChars] characters of a build's output, in whole lines. Thread-safe. */
+internal class BuildOutputCapture(
+ private val maxChars: Int,
+) {
+ private val lines = ArrayDeque()
+ private var chars = 0
+
+ @Synchronized
+ fun append(line: String) {
+ lines.addLast(line)
+ chars += line.length + 1
+ while (chars > maxChars && lines.size > 1) chars -= lines.removeFirst().length + 1
+ }
+
+ @Synchronized
+ fun text(): String = lines.joinToString("\n")
+}
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..266b85fd7d 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,21 @@ 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.GradleTaskInfo
+import com.itsaky.androidide.plugins.services.GradleTaskResult
import com.itsaky.androidide.plugins.services.IdeBuildService
+import com.itsaky.androidide.project.GradleModels
+import com.itsaky.androidide.projects.IProjectManager
import com.itsaky.androidide.projects.builder.BuildService
+import com.itsaky.androidide.tooling.api.messages.BuildId
+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
/**
@@ -31,6 +42,12 @@ class IdeBuildServiceImpl private constructor() : IdeBuildService {
companion object {
private val log = LoggerFactory.getLogger(IdeBuildServiceImpl::class.java)
+ /** The reason a plugin is given whenever another build holds the Gradle slot. */
+ const val BUILD_IN_PROGRESS_REASON = "another build is in progress"
+
+ // What a plugin's run keeps of its output: the tail, where Gradle reports a failure.
+ private const val MAX_OUTPUT_CHARS = 128 * 1024
+
@Volatile
private var instance: IdeBuildServiceImpl? = null
@@ -38,6 +55,24 @@ class IdeBuildServiceImpl private constructor() : IdeBuildService {
instance ?: synchronized(this) {
instance ?: IdeBuildServiceImpl().also { instance = it }
}
+
+ /** The tasks of [build]'s root project and modules, in that order; empty before a sync. */
+ internal fun tasksOf(build: GradleModels.GradleBuild?): List {
+ if (build == null) return emptyList()
+ val projects = listOfNotNull(build.rootProject.takeIf { build.hasRootProject() }) + build.subProjectList
+ // Sync puts the root in subProjectList too (RootModelBuilder maps every IDEA module).
+ return projects.flatMap { it.taskList }.distinctBy { it.path }.map { it.toInfo() }
+ }
+
+ // Proto3 reads an unset optional string as "", which a plugin would take for a real value.
+ private fun GradleModels.GradleTask.toInfo() =
+ GradleTaskInfo(
+ path = path,
+ name = name,
+ projectPath = projectPath,
+ group = group.takeIf { hasGroup() && it.isNotBlank() },
+ description = description.takeIf { hasDescription() && it.isNotBlank() },
+ )
}
override fun isBuildInProgress(): Boolean = buildInProgress
@@ -109,29 +144,133 @@ 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 = startTasks(tasks, arguments).result
+
+ /** Gradle output of the runs [startTasks] has in flight; fed by [onBuildOutput]. */
+ private val outputCaptures = CopyOnWriteArraySet()
+
+ /**
+ * Called by Code On the Go's build system with each line Gradle prints. Only one build holds
+ * the slot at a time, so every line seen while a run is in flight is that run's.
+ */
+ fun onBuildOutput(line: String) {
+ outputCaptures.forEach { it.append(line.removeSuffix("\n")) }
+ }
+
+ /** Runs [tasks] like [executeTasks], and also gives the caller this run's output and a cancel for it. */
+ internal fun startTasks(
+ tasks: List,
+ arguments: List,
+ ): GradleTaskRun {
+ if (tasks.isEmpty()) return refuse(tasks, "no tasks were given")
+ // Tasks are passed as command-line arguments, so "-I x" would be read as an option.
+ tasks.firstOrNull { it.isBlank() || it.startsWith("-") }?.let { return refuse(tasks, "'$it' is not a task") }
val buildService =
Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)
?: return refuse(tasks, "build service is not registered")
if (!buildService.isToolingServerStarted()) return refuse(tasks, "tooling server is not started")
- if (buildService.isBuildInProgress) return refuse(tasks, "another build is in progress")
+ if (buildService.isBuildInProgress) return refuse(tasks, BUILD_IN_PROGRESS_REASON)
- return buildService.executeTasks(*tasks).handle { result, error ->
- if (error != null) {
- log.error("Tasks {} failed", tasks.toList(), error)
- }
- error == null && result?.isSuccessful == true
+ val buildId = buildService.nextBuildId(BuildRunType.TaskRun)
+ val message =
+ TaskExecutionMessage(
+ tasks = tasks,
+ buildId = buildId,
+ buildParams = GradleBuildParams(gradleArgs = arguments),
+ )
+ val capture = BuildOutputCapture(MAX_OUTPUT_CHARS)
+ outputCaptures += capture
+ val result =
+ runCatching { buildService.executeTasks(message) }
+ .getOrElse { CompletableFuture().apply { completeExceptionally(it) } }
+ .handle { result, error ->
+ outputCaptures -= capture
+ toGradleTaskResult(tasks, result, error)
+ }
+ return ToolingServerRun(result, capture, buildId, buildService)
+ }
+
+ private fun toGradleTaskResult(
+ tasks: List,
+ result: TaskExecutionResult?,
+ error: Throwable?,
+ ): GradleTaskResult {
+ if (error != null) {
+ log.error("Tasks {} failed", tasks, error)
+ val cause = (error as? CompletionException)?.cause ?: error
+ return GradleTaskResult.Failed(cause.message ?: cause.javaClass.simpleName)
+ }
+ return when {
+ result == null -> GradleTaskResult.Failed(TaskExecutionResult.Failure.UNKNOWN.name)
+
+ result.isSuccessful -> GradleTaskResult.Success
+
+ result.failure == TaskExecutionResult.Failure.BUILD_CANCELLED -> GradleTaskResult.Cancelled
+
+ // Another build took the slot between the check above and the service's own claim.
+ result.failure == TaskExecutionResult.Failure.BUILD_IN_PROGRESS -> GradleTaskResult.Refused(BUILD_IN_PROGRESS_REASON)
+
+ else -> GradleTaskResult.Failed((result.failure ?: TaskExecutionResult.Failure.UNKNOWN).name)
}
}
private fun refuse(
- tasks: Array,
+ tasks: List,
reason: String,
- ): CompletableFuture {
- log.warn("Not executing tasks {}: {}", tasks.toList(), reason)
- return CompletableFuture.completedFuture(false)
+ ): GradleTaskRun {
+ log.warn("Not executing tasks {}: {}", tasks, reason)
+ return RefusedRun(reason)
}
+ private class RefusedRun(
+ reason: String,
+ ) : GradleTaskRun {
+ override val result: CompletableFuture = CompletableFuture.completedFuture(GradleTaskResult.Refused(reason))
+
+ override fun output() = ""
+
+ override fun cancel(): CompletableFuture = CompletableFuture.completedFuture(false)
+ }
+
+ private inner class ToolingServerRun(
+ override val result: CompletableFuture,
+ private val capture: BuildOutputCapture,
+ private val buildId: BuildId,
+ private val buildService: BuildService,
+ ) : GradleTaskRun {
+ // A run refused at the slot claim captured the lines of the build that holds it.
+ override fun output() = if (result.getNow(null) is GradleTaskResult.Refused) "" else capture.text()
+
+ override fun cancel(): CompletableFuture =
+ if (!result.isDone && buildService.currentBuildId == buildId) {
+ cancelCurrentBuild(buildService)
+ } else {
+ CompletableFuture.completedFuture(false)
+ }
+ }
+
+ override fun cancelBuild(): CompletableFuture {
+ val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)
+ if (buildService == null || !buildService.isToolingServerStarted()) {
+ return CompletableFuture.completedFuture(false)
+ }
+ return cancelCurrentBuild(buildService)
+ }
+
+ private fun cancelCurrentBuild(buildService: BuildService): CompletableFuture =
+ 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) {
runAppProvider?.invoke(callback)
?: callback.onComplete(false, "Run app functionality not initialized")
@@ -144,6 +283,8 @@ class IdeBuildServiceImpl private constructor() : IdeBuildService {
override fun getBuildOutput(): String? = buildOutputProvider?.invoke()
+ override fun getTasks(): List = tasksOf(IProjectManager.getInstance().gradleBuild)
+
/**
* Set the run app provider (should be called by Code On the Go's app module during initialization)
*/
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt
index a334b1b2a4..09df1d092c 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
@@ -20,18 +20,17 @@ 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
-class IdeCommandServiceImpl(
+class IdeCommandServiceImpl internal constructor(
private val pluginId: String,
private val permissions: Set,
private val projectRootProvider: () -> File?,
private val appFilesDir: File,
+ private val gradleTaskRunner: GradleTaskRunner = GradleTaskRunner(IdeBuildServiceImpl.getInstance()::startTasks),
) : IdeCommandService {
- private val runningCommands = ConcurrentHashMap()
+ private val runningCommands = ConcurrentHashMap()
override fun executeCommand(
spec: CommandSpec,
@@ -41,56 +40,32 @@ class IdeCommandServiceImpl(
requireConcurrencyLimit()
val executionId = "$pluginId-${UUID.randomUUID()}"
- val projectRoot = projectRootProvider()
-
- val processBuilder =
+ val execution =
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)
- }
- }
+ // 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.
+ is CommandSpec.GradleTask -> GradleTaskExecution(executionId, spec, gradleTaskRunner, timeoutMs)
- 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)
- }
- }
+ is CommandSpec.ShellCommand -> CommandExecutionImpl(executionId, shellProcess(spec), timeoutMs)
}
-
- processBuilder.redirectErrorStream(false)
- TermuxProcessEnvironment.applyTo(processBuilder.environment(), appFilesDir)
-
- val execution =
- CommandExecutionImpl(
- executionId = executionId,
- processBuilder = processBuilder,
- timeoutMs = timeoutMs,
- )
runningCommands[executionId] = execution
execution.start { runningCommands.remove(executionId) }
return execution
}
+ private fun shellProcess(spec: CommandSpec.ShellCommand): ProcessBuilder {
+ val workDir = resolvePluginWorkingDirectory(pluginId, projectRootProvider(), spec.workingDirectory)
+ return ProcessBuilder(listOf(spec.executable) + spec.arguments).apply {
+ workDir?.let { directory(it) }
+ environment().putAll(spec.environment)
+ redirectErrorStream(false)
+ TermuxProcessEnvironment.applyTo(environment(), appFilesDir)
+ }
+ }
+
override fun isCommandRunning(executionId: String): Boolean = runningCommands[executionId]?.isRunning() == true
+ // onComplete removes the entry: a GradleTask stays running until Gradle has stopped.
override fun cancelCommand(executionId: String): Boolean =
runningCommands[executionId]?.let {
it.cancel()
@@ -122,28 +97,23 @@ 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 {
+ /** Starts the command; [onComplete] runs once it ends or is cancelled. */
+ fun start(onComplete: () -> Unit)
+
+ 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 +121,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) {
+ override fun start(onComplete: () -> Unit) {
+ this.onComplete = onComplete
scope.launch {
val startTime = System.currentTimeMillis()
@@ -240,9 +215,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..d3a92935ca
--- /dev/null
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImpl.kt
@@ -0,0 +1,142 @@
+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.CancellableContinuation
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import org.slf4j.LoggerFactory
+import java.io.File
+import java.util.concurrent.ConcurrentHashMap
+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 {
+ // Cancelled on unload: a command run from a scope the plugin never cancels would outlive it.
+ private val running = ConcurrentHashMap.newKeySet>()
+
+ // Set by cancelAll, cleared by reopen. A run still in its IO checks is not in [running] yet and
+ // must not launch after.
+ @Volatile
+ private var closed = false
+
+ 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")
+ }
+ // Disk checks off the caller's thread: plugins call this from main-thread coroutines.
+ val (workDir, notStarted) =
+ withContext(Dispatchers.IO) {
+ val dir = resolvePluginWorkingDirectory(pluginId, projectRootProvider(), workingDirectory)
+ dir to
+ when {
+ dir != null && !dir.isDirectory -> "Working directory does not exist: $dir"
+ bashProvider()?.canExecute() != true -> "The terminal environment is not installed"
+ else -> null
+ }
+ }
+ if (notStarted != null) return TerminalCommandResult.NotStarted(notStarted)
+ val launcher = launcherProvider() ?: return TerminalCommandResult.NotStarted("The Terminal is not available")
+
+ return suspendCancellableCoroutine { continuation ->
+ running += continuation
+ // Checked after joining [running]: cancelAll either sees this run or this run sees closed.
+ if (closed) {
+ running -= continuation
+ continuation.cancel()
+ return@suspendCancellableCoroutine
+ }
+ val kill =
+ launcher.launch(command, workDir, pluginId) {
+ running -= continuation
+ continuation.resume(it)
+ }
+ continuation.invokeOnCancellation {
+ running -= continuation
+ kill()
+ }
+ }
+ }
+
+ /**
+ * Kills this plugin's terminal commands; each caller's [runInTerminal] is cancelled, and so is
+ * any later call until [reopen]. Called on disable and unload.
+ */
+ fun cancelAll() {
+ closed = true
+ running.forEach { it.cancel() }
+ }
+
+ /** Lets [runInTerminal] launch again after [cancelAll]. Called when the plugin is re-enabled. */
+ fun reopen() {
+ closed = false
+ }
+
+ 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/PluginBuildService.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/PluginBuildService.kt
new file mode 100644
index 0000000000..dd5c5cf831
--- /dev/null
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/PluginBuildService.kt
@@ -0,0 +1,36 @@
+package com.itsaky.androidide.plugins.manager.services
+
+import com.itsaky.androidide.plugins.PluginPermission
+import com.itsaky.androidide.plugins.services.GradleTaskResult
+import com.itsaky.androidide.plugins.services.IdeBuildService
+import java.util.concurrent.CompletableFuture
+
+/**
+ * One plugin's view of the shared [IdeBuildServiceImpl]. Gradle arguments (`--init-script` runs
+ * any code in the IDE's daemon) and cancelling someone else's build need [PluginPermission.SYSTEM_COMMANDS],
+ * as [CommandSpec.GradleTask][com.itsaky.androidide.plugins.extensions.CommandSpec.GradleTask] does.
+ */
+class PluginBuildService(
+ private val pluginId: String,
+ private val permissions: Set,
+ private val delegate: IdeBuildService = IdeBuildServiceImpl.getInstance(),
+) : IdeBuildService by delegate {
+ override fun executeTasks(
+ tasks: List,
+ arguments: List,
+ ): CompletableFuture {
+ if (arguments.isNotEmpty()) requireSystemCommands("pass Gradle arguments")
+ return delegate.executeTasks(tasks, arguments)
+ }
+
+ override fun cancelBuild(): CompletableFuture {
+ requireSystemCommands("cancel a build")
+ return delegate.cancelBuild()
+ }
+
+ private fun requireSystemCommands(action: String) {
+ if (PluginPermission.SYSTEM_COMMANDS !in permissions) {
+ throw SecurityException("Plugin $pluginId needs the SYSTEM_COMMANDS permission to $action")
+ }
+ }
+}
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..1df472f096 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,7 +2,10 @@ 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.BuildId
+import com.itsaky.androidide.tooling.api.messages.BuildRunType
import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams
import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage
import com.itsaky.androidide.tooling.api.messages.result.BuildCancellationRequestResult
@@ -18,34 +21,61 @@ 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 {
+ override var currentBuildId: BuildId? = null
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 metadata(): CompletableFuture = unsupported()
+ override fun nextBuildId(runType: BuildRunType) = BuildId("session", ++lastBuildId, runType)
- override fun initializeProject(params: InitializeProjectParams): CompletableFuture = unsupported()
+ private var lastBuildId = 0L
+
+ override fun cancelCurrentBuild(): CompletableFuture {
+ cancelRequests++
+ return cancellation()
+ }
- override fun executeTasks(message: TaskExecutionMessage): CompletableFuture = unsupported()
+ override fun metadata(): CompletableFuture = unsupported()
- override fun cancelCurrentBuild(): CompletableFuture = unsupported()
+ override fun initializeProject(params: InitializeProjectParams): CompletableFuture = unsupported()
private fun unsupported(): Nothing = throw UnsupportedOperationException()
}
private fun register(service: FakeBuildService) = service.also { Lookup.getDefault().register(BuildService.KEY_BUILD_SERVICE, it) }
- private fun execute(): Boolean =
+ private fun execute(vararg tasks: String = arrayOf(":app:assembleDebug")): Boolean =
+ IdeBuildServiceImpl
+ .getInstance()
+ .executeTasks(*tasks)
+ .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()
- .executeTasks(":app:assembleDebug")
+ .cancelBuild()
.get(5, TimeUnit.SECONDS)
@After
@@ -110,4 +140,201 @@ class IdeBuildServiceImplExecuteTasksTest {
assertThat(execute()).isFalse()
assertThat(service.executed).isEmpty()
}
+
+ @Test
+ fun argumentsReachTheToolingServer() {
+ val service = register(FakeBuildService { CompletableFuture.completedFuture(TaskExecutionResult.SUCCESS) })
+
+ val result = run(arguments = listOf("--tests", "com.example.FooTest", "-Pflag=true"))
+
+ assertThat(result).isEqualTo(GradleTaskResult.Success)
+ assertThat(service.messages.single().tasks).containsExactly(":app:testDebugUnitTest")
+ assertThat(
+ service.messages
+ .single()
+ .buildParams.gradleArgs,
+ ).containsExactly("--tests", "com.example.FooTest", "-Pflag=true")
+ .inOrder()
+ }
+
+ @Test
+ fun failedBuildReportsTheFailure() {
+ register(
+ FakeBuildService {
+ CompletableFuture.completedFuture(TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_FAILED))
+ },
+ )
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Failed("BUILD_FAILED"))
+ }
+
+ @Test
+ fun cancelledBuildReportsCancelled() {
+ register(
+ FakeBuildService {
+ CompletableFuture.completedFuture(TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_CANCELLED))
+ },
+ )
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Cancelled)
+ }
+
+ @Test
+ fun exceptionalBuildReportsTheCause() {
+ register(
+ FakeBuildService {
+ CompletableFuture().apply { completeExceptionally(IllegalStateException("gradle died")) }
+ },
+ )
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Failed("gradle died"))
+ }
+
+ @Test
+ fun buildInProgressIsRefusedWithTheReason() {
+ val service = register(FakeBuildService(isBuildInProgress = true) { error("must not execute") })
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Refused("another build is in progress"))
+ assertThat(service.executed).isEmpty()
+ }
+
+ @Test
+ fun buildThatTakesTheSlotAfterTheCheckIsRefused() {
+ register(
+ FakeBuildService {
+ CompletableFuture.completedFuture(TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_IN_PROGRESS))
+ },
+ )
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Refused("another build is in progress"))
+ }
+
+ @Test
+ fun stoppedToolingServerIsRefusedWithTheReason() {
+ val service = register(FakeBuildService(serverStarted = false) { error("must not execute") })
+
+ assertThat(run()).isEqualTo(GradleTaskResult.Refused("tooling server is not started"))
+ assertThat(service.executed).isEmpty()
+ }
+
+ @Test
+ fun missingBuildServiceIsRefused() {
+ assertThat(run()).isEqualTo(GradleTaskResult.Refused("build service is not registered"))
+ }
+
+ @Test
+ fun noTasksIsRefusedWithoutExecuting() {
+ val service = register(FakeBuildService { error("must not execute") })
+
+ assertThat(run(tasks = emptyList())).isInstanceOf(GradleTaskResult.Refused::class.java)
+ assertThat(service.executed).isEmpty()
+ }
+
+ @Test
+ fun cancelBuildReportsAnEnqueuedCancellation() {
+ val service =
+ register(
+ FakeBuildService(
+ cancellation = { CompletableFuture.completedFuture(BuildCancellationRequestResult(true)) },
+ ) { error("must not execute") },
+ )
+
+ assertThat(cancel()).isTrue()
+ assertThat(service.cancelRequests).isEqualTo(1)
+ }
+
+ @Test
+ fun cancelBuildReportsFalseWhenNothingIsRunning() {
+ register(
+ FakeBuildService(
+ cancellation = {
+ CompletableFuture.completedFuture(
+ BuildCancellationRequestResult(false, BuildCancellationRequestResult.Reason.NO_RUNNING_BUILD),
+ )
+ },
+ ) { error("must not execute") },
+ )
+
+ assertThat(cancel()).isFalse()
+ }
+
+ @Test
+ fun cancelBuildReportsFalseWithoutAToolingServer() {
+ val service = register(FakeBuildService(serverStarted = false) { error("must not execute") })
+
+ assertThat(cancel()).isFalse()
+ assertThat(service.cancelRequests).isEqualTo(0)
+ }
+
+ @Test
+ fun cancelBuildReportsFalseWithoutABuildService() {
+ assertThat(cancel()).isFalse()
+ }
+
+ @Test
+ fun optionLikeTaskNameIsRefusedWithoutExecuting() {
+ val service = register(FakeBuildService { error("must not execute") })
+
+ assertThat(run(tasks = listOf("help", "-I"))).isEqualTo(GradleTaskResult.Refused("'-I' is not a task"))
+ assertThat(execute("--init-script", "/tmp/evil.gradle")).isFalse()
+ assertThat(service.executed).isEmpty()
+ }
+
+ @Test
+ fun runOutputHoldsOnlyTheLinesPrintedWhileItRan() {
+ val pending = CompletableFuture()
+ register(FakeBuildService { pending })
+ val impl = IdeBuildServiceImpl.getInstance()
+ impl.onBuildOutput("an earlier build")
+
+ val run = impl.startTasks(listOf(":app:test"), emptyList())
+ impl.onBuildOutput("> Task :app:test\n")
+ impl.onBuildOutput("\n")
+ impl.onBuildOutput("BUILD SUCCESSFUL\n")
+ pending.complete(TaskExecutionResult.SUCCESS)
+ run.result.get(5, TimeUnit.SECONDS)
+ impl.onBuildOutput("a later build")
+
+ assertThat(run.output()).isEqualTo("> Task :app:test\n\nBUILD SUCCESSFUL")
+ }
+
+ @Test
+ fun runRefusedAtTheSlotClaimHasNoOutput() {
+ val pending = CompletableFuture()
+ register(FakeBuildService { pending })
+ val impl = IdeBuildServiceImpl.getInstance()
+
+ val run = impl.startTasks(listOf(":app:test"), emptyList())
+ impl.onBuildOutput("the other build's line")
+ pending.complete(TaskExecutionResult(false, TaskExecutionResult.Failure.BUILD_IN_PROGRESS))
+ run.result.get(5, TimeUnit.SECONDS)
+
+ assertThat(run.output()).isEmpty()
+ }
+
+ @Test
+ fun runCancelsItsOwnBuild() {
+ val service =
+ register(
+ FakeBuildService(
+ cancellation = { CompletableFuture.completedFuture(BuildCancellationRequestResult(true)) },
+ ) { CompletableFuture() },
+ )
+ val run = IdeBuildServiceImpl.getInstance().startTasks(listOf(":app:test"), emptyList())
+ service.currentBuildId = service.messages.single().buildId
+
+ assertThat(run.cancel().get(5, TimeUnit.SECONDS)).isTrue()
+ assertThat(service.cancelRequests).isEqualTo(1)
+ }
+
+ @Test
+ fun runLeavesAnotherBuildAlone() {
+ val service = register(FakeBuildService { CompletableFuture() })
+ val run = IdeBuildServiceImpl.getInstance().startTasks(listOf(":app:test"), emptyList())
+ // The run's slot was released and someone else's build took it.
+ service.currentBuildId = BuildId("session", 99, BuildRunType.ProjectSync)
+
+ assertThat(run.cancel().get(5, TimeUnit.SECONDS)).isFalse()
+ assertThat(service.cancelRequests).isEqualTo(0)
+ }
}
diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImplGetTasksTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImplGetTasksTest.kt
new file mode 100644
index 0000000000..20ab604541
--- /dev/null
+++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImplGetTasksTest.kt
@@ -0,0 +1,97 @@
+package com.itsaky.androidide.plugins.manager.services
+
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.plugins.services.GradleTaskInfo
+import com.itsaky.androidide.project.GradleModels
+import org.junit.Test
+
+class IdeBuildServiceImplGetTasksTest {
+ private fun task(
+ projectPath: String,
+ name: String,
+ group: String? = null,
+ description: String? = null,
+ ): GradleModels.GradleTask =
+ GradleModels.GradleTask
+ .newBuilder()
+ .setName(name)
+ .setPath(if (projectPath == ":") ":$name" else "$projectPath:$name")
+ .setProjectPath(projectPath)
+ .setIsPublic(group != null)
+ .apply { group?.let(::setGroup) }
+ .apply { description?.let(::setDescription) }
+ .build()
+
+ private fun project(
+ path: String,
+ vararg tasks: GradleModels.GradleTask,
+ ): GradleModels.GradleProject =
+ GradleModels.GradleProject
+ .newBuilder()
+ .setName(path.trimStart(':').ifEmpty { "root" })
+ .setPath(path)
+ .addAllTask(tasks.toList())
+ .build()
+
+ @Test
+ fun givenNoSyncedBuild_whenListingTasks_thenReturnsEmpty() {
+ assertThat(IdeBuildServiceImpl.tasksOf(null)).isEmpty()
+ }
+
+ @Test
+ fun givenRootAndModuleTasks_whenListingTasks_thenReturnsRootTasksFirst() {
+ val build =
+ GradleModels.GradleBuild
+ .newBuilder()
+ .setRootProject(project(":", task(":", "printGreeting", "custom", "Prints a greeting")))
+ .addSubProject(project(":app", task(":app", "testDebugUnitTest", "verification", "Run unit tests")))
+ .build()
+
+ assertThat(IdeBuildServiceImpl.tasksOf(build))
+ .containsExactly(
+ GradleTaskInfo(":printGreeting", "printGreeting", ":", "custom", "Prints a greeting"),
+ GradleTaskInfo(":app:testDebugUnitTest", "testDebugUnitTest", ":app", "verification", "Run unit tests"),
+ ).inOrder()
+ }
+
+ @Test
+ fun givenTaskWithoutGroupOrDescription_whenListingTasks_thenReportsNullNotEmpty() {
+ val build =
+ GradleModels.GradleBuild
+ .newBuilder()
+ .addSubProject(project(":app", task(":app", "compileDebugKotlin"), task(":app", "lint", "", " ")))
+ .build()
+
+ val tasks = IdeBuildServiceImpl.tasksOf(build)
+
+ assertThat(tasks.map { it.group }).containsExactly(null, null)
+ assertThat(tasks.map { it.description }).containsExactly(null, null)
+ }
+
+ @Test
+ fun givenRootAlsoInSubProjects_whenListingTasks_thenEachTaskIsListedOnce() {
+ val root = project(":", task(":", "secretHandshake", description = "Prints a greeting"))
+ val build =
+ GradleModels.GradleBuild
+ .newBuilder()
+ .setRootProject(root)
+ .addSubProject(root)
+ .addSubProject(project(":app", task(":app", "secretHandshake", description = "Prints a greeting")))
+ .build()
+
+ assertThat(IdeBuildServiceImpl.tasksOf(build).map { it.path })
+ .containsExactly(":secretHandshake", ":app:secretHandshake")
+ .inOrder()
+ }
+
+ @Test
+ fun givenBuildWithoutRootProject_whenListingTasks_thenReturnsModuleTasksOnly() {
+ val build =
+ GradleModels.GradleBuild
+ .newBuilder()
+ .addSubProject(project(":lib", task(":lib", "assemble", "build")))
+ .build()
+
+ assertThat(IdeBuildServiceImpl.tasksOf(build).map { it.path }).containsExactly(":lib:assemble")
+ }
+}
diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImplGradleTaskTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImplGradleTaskTest.kt
new file mode 100644
index 0000000000..f0e3bdd120
--- /dev/null
+++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImplGradleTaskTest.kt
@@ -0,0 +1,233 @@
+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.GradleTaskResult
+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 FakeRun(
+ override val result: CompletableFuture,
+ private val output: String?,
+ private val onCancel: () -> Unit,
+ ) : GradleTaskRun {
+ override fun output(): String = output.orEmpty()
+
+ override fun cancel(): CompletableFuture {
+ onCancel()
+ return CompletableFuture.completedFuture(true)
+ }
+ }
+
+ private class FakeRunner(
+ private val buildOutput: String? = "> Task :app:test\nBUILD SUCCESSFUL",
+ private val onCancel: (CompletableFuture) -> Unit = {},
+ private val result: () -> CompletableFuture,
+ ) : GradleTaskRunner {
+ val runs = mutableListOf, List>>()
+ val started = mutableListOf()
+ var cancelRequests = 0
+
+ override fun start(
+ tasks: List,
+ arguments: List,
+ ): GradleTaskRun {
+ runs += tasks to arguments
+ val future = result()
+ return FakeRun(future, buildOutput) {
+ cancelRequests++
+ onCancel(future)
+ }.also { started += it }
+ }
+ }
+
+ private val spec = CommandSpec.GradleTask(":app:testDebugUnitTest", listOf("--tests", "com.example.FooTest"))
+
+ private fun service(
+ runner: GradleTaskRunner,
+ 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"),
+ gradleTaskRunner = runner,
+ )
+
+ private fun await(block: suspend () -> T): T = runBlocking { withTimeout(5_000) { block() } }
+
+ @Test
+ fun gradleTaskRunsOnTheToolingServerWithItsArguments() {
+ val build = FakeRunner { 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 = FakeRunner { 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 =
+ FakeRunner(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 = FakeRunner { 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 =
+ FakeRunner { 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")
+ assertThat(result.stdout).isEmpty()
+ }
+
+ @Test
+ fun cancellingARunningTaskCompletesWhenTheBuildStops() {
+ val build = FakeRunner(onCancel = { it.complete(GradleTaskResult.Cancelled) }) { CompletableFuture() }
+ val service = service(build)
+ val execution = service.executeCommand(spec)
+
+ assertThat(service.isCommandRunning(execution.executionId)).isTrue()
+ execution.cancel()
+
+ assertThat(build.cancelRequests).isEqualTo(1)
+ val result = await { execution.await() } as CommandResult.Cancelled
+ // The run's own output, not an empty placeholder.
+ assertThat(result.partialStdout).isEqualTo("> Task :app:test\nBUILD SUCCESSFUL")
+ assertThat(service.getRunningCommandCount()).isEqualTo(0)
+ }
+
+ @Test
+ fun cancelledTaskStaysRunningUntilTheBuildStops() {
+ val build = FakeRunner { CompletableFuture() }
+ val service = service(build)
+ val execution = service.executeCommand(spec)
+
+ assertThat(service.cancelCommand(execution.executionId)).isTrue()
+
+ // The build still holds the slot, so the command is not over.
+ assertThat(service.isCommandRunning(execution.executionId)).isTrue()
+ build.started
+ .single()
+ .result
+ .complete(GradleTaskResult.Cancelled)
+ assertThat(await { execution.await() }).isInstanceOf(CommandResult.Cancelled::class.java)
+ }
+
+ @Test
+ fun cancelledCommandsFreeTheirConcurrencySlots() {
+ val build = FakeRunner(onCancel = { it.complete(GradleTaskResult.Cancelled) }) { 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 cancellingAFinishedTaskLeavesLaterBuildsAlone() {
+ val build = FakeRunner { CompletableFuture.completedFuture(GradleTaskResult.Success) }
+ val execution = service(build).executeCommand(spec)
+ await { execution.await() }
+
+ execution.cancel()
+
+ assertThat(build.cancelRequests).isEqualTo(0)
+ }
+
+ @Test
+ fun timeoutCancelsTheBuildAndReportsATimeout() {
+ val build = FakeRunner(onCancel = { it.complete(GradleTaskResult.Cancelled) }) { CompletableFuture() }
+
+ val result = await { service(build).executeCommand(spec, timeoutMs = 50).await() } as CommandResult.Failure
+
+ assertThat(build.cancelRequests).isEqualTo(1)
+ assertThat(result.error).isEqualTo("Gradle task timed out after 50ms")
+ }
+
+ @Test
+ fun timeoutCompletesEvenIfGradleIgnoresTheCancel() {
+ val build = FakeRunner { CompletableFuture() }
+ val execution =
+ GradleTaskExecution("id", spec, build, timeoutMs = 50, stopGraceMs = 50).apply { start {} }
+
+ val result = await { execution.await() } as CommandResult.Failure
+
+ assertThat(result.error).contains("did not stop")
+ assertThat(result.stdout).isEqualTo("> Task :app:test\nBUILD SUCCESSFUL")
+ }
+
+ @Test
+ fun cancelCompletesEvenIfGradleIgnoresIt() {
+ val build = FakeRunner { CompletableFuture() }
+ val execution =
+ GradleTaskExecution("id", spec, build, timeoutMs = 60_000, stopGraceMs = 50).apply { start {} }
+
+ execution.cancel()
+
+ assertThat(await { execution.await() }).isInstanceOf(CommandResult.Cancelled::class.java)
+ }
+
+ @Test(expected = SecurityException::class)
+ fun gradleTaskStillRequiresSystemCommands() {
+ val build = FakeRunner { 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..08ad2d8fda
--- /dev/null
+++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImplTest.kt
@@ -0,0 +1,206 @@
+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),
+ launcherProvider: () -> TerminalSessionLauncher? = { launcher },
+ ) = IdeTerminalServiceImpl(
+ pluginId = "test.plugin",
+ permissions = permissions,
+ projectRootProvider = { projectRoot },
+ appFilesDir = tmp.root,
+ bashProvider = { bash },
+ launcherProvider = launcherProvider,
+ )
+
+ 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()
+ }
+
+ @Test
+ fun cancelAllKillsTheCommandAndCancelsItsCaller() {
+ val launcher = FakeLauncher(result = null)
+ val service = service(launcher = launcher)
+
+ runBlocking {
+ val run = async { service.runInTerminal("sleep 100") }
+ while (launcher.launches.isEmpty()) yield()
+ service.cancelAll()
+ withTimeout(5_000) { run.join() }
+ assertThat(run.isCancelled).isTrue()
+ }
+
+ assertThat(launcher.killed).isTrue()
+ }
+
+ @Test
+ fun cancelAllDuringTheChecksStopsTheLaunch() {
+ val launcher = FakeLauncher(result = null)
+ lateinit var service: IdeTerminalServiceImpl
+ // The launcher is looked up after the IO checks, just before launching: unload lands there.
+ service =
+ service(launcherProvider = {
+ service.cancelAll()
+ launcher
+ })
+
+ runBlocking {
+ val run = async { service.runInTerminal("sleep 100") }
+ withTimeout(5_000) { run.join() }
+ assertThat(run.isCancelled).isTrue()
+ }
+
+ assertThat(launcher.launches).isEmpty()
+ }
+
+ @Test
+ fun reopenAfterCancelAllRunsAgain() {
+ // Disable then enable reuses the same service instance.
+ val service = service()
+ service.cancelAll()
+ service.reopen()
+
+ val result = await { service.runInTerminal("ls") }
+
+ assertThat(result).isEqualTo(TerminalCommandResult.Completed(0, "$ ls"))
+ }
+}
diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/PluginBuildServiceTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/PluginBuildServiceTest.kt
new file mode 100644
index 0000000000..3d60d790fc
--- /dev/null
+++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/PluginBuildServiceTest.kt
@@ -0,0 +1,74 @@
+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.BuildStatusListener
+import com.itsaky.androidide.plugins.services.GradleTaskResult
+import com.itsaky.androidide.plugins.services.IdeBuildService
+import org.junit.Test
+import java.util.concurrent.CompletableFuture
+
+class PluginBuildServiceTest {
+ private class FakeIdeBuildService : IdeBuildService {
+ val runs = mutableListOf, List>>()
+ var cancelRequests = 0
+
+ override fun executeTasks(
+ tasks: List,
+ arguments: List,
+ ): CompletableFuture {
+ runs += tasks to arguments
+ return CompletableFuture.completedFuture(GradleTaskResult.Success)
+ }
+
+ override fun cancelBuild(): CompletableFuture {
+ cancelRequests++
+ return CompletableFuture.completedFuture(true)
+ }
+
+ override fun isBuildInProgress() = false
+
+ override fun isToolingServerStarted() = true
+
+ override fun addBuildStatusListener(callback: BuildStatusListener) = Unit
+
+ override fun removeBuildStatusListener(callback: BuildStatusListener) = Unit
+ }
+
+ private val delegate = FakeIdeBuildService()
+
+ private fun service(vararg permissions: PluginPermission) = PluginBuildService("test.plugin", permissions.toSet(), delegate)
+
+ @Test
+ fun tasksWithoutArgumentsNeedNoPermission() {
+ service().executeTasks(listOf(":app:test"), emptyList())
+
+ assertThat(delegate.runs).containsExactly(listOf(":app:test") to emptyList())
+ }
+
+ @Test
+ fun argumentsNeedSystemCommands() {
+ val error =
+ runCatching { service().executeTasks(listOf("help"), listOf("--init-script", "/tmp/evil.gradle")) }
+ .exceptionOrNull()
+
+ assertThat(error).isInstanceOf(SecurityException::class.java)
+ assertThat(delegate.runs).isEmpty()
+ }
+
+ @Test
+ fun argumentsRunWithSystemCommands() {
+ service(PluginPermission.SYSTEM_COMMANDS).executeTasks(listOf(":app:test"), listOf("--info"))
+
+ assertThat(delegate.runs).containsExactly(listOf(":app:test") to listOf("--info"))
+ }
+
+ @Test
+ fun cancelBuildNeedsSystemCommands() {
+ assertThat(runCatching { service().cancelBuild() }.exceptionOrNull()).isInstanceOf(SecurityException::class.java)
+ assertThat(delegate.cancelRequests).isEqualTo(0)
+
+ service(PluginPermission.SYSTEM_COMMANDS).cancelBuild()
+ assertThat(delegate.cancelRequests).isEqualTo(1)
+ }
+}
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/projects/src/main/java/com/itsaky/androidide/projects/builder/BuildService.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/builder/BuildService.kt
index d0c60f3305..e0c877b815 100644
--- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/builder/BuildService.kt
+++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/builder/BuildService.kt
@@ -102,6 +102,13 @@ interface BuildService {
*/
fun executeTasks(message: TaskExecutionMessage): CompletableFuture
+ /**
+ * The id of the [TaskExecutionMessage] whose build holds the slot, or null when no build does
+ * or the one that does (a sync) has no id. Lets a caller cancel its own build and nobody else's.
+ */
+ val currentBuildId: BuildId?
+ get() = null
+
/** Cancel any running build. */
fun cancelCurrentBuild(): CompletableFuture
}
diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt
index 1943edb4c1..5068175837 100644
--- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt
+++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt
@@ -309,8 +309,8 @@ internal class ToolingApiServerImpl(
builder.setStandardInput("NoOp".byteInputStream())
builder.setStandardError(out)
builder.setStandardOutput(out)
- builder.forTasks(*message.tasks.filter { it.isNotBlank() }.toTypedArray())
- builder.configureFrom(clientConfig, message.buildParams)
+ // Tasks go in the arguments, not forTasks, so a task option in buildParams binds to them.
+ builder.configureFrom(clientConfig, message.buildParams, message.tasks)
this.buildCancellationToken = GradleConnector.newCancellationTokenSource()
builder.withCancellationToken(this.buildCancellationToken!!.token())
diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExts.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExts.kt
index 6b16ab3555..a19bbbfba2 100644
--- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExts.kt
+++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExts.kt
@@ -12,9 +12,15 @@ import java.nio.charset.StandardCharsets
private val logger = LoggerFactory.getLogger("GradleBuildExts")
+/**
+ * Configures this launcher's output, progress and arguments. [tasks] go into the argument list,
+ * between the client's arguments and [buildParams]', as on a command line: a task option such as
+ * `--tests` or `--rerun` binds to the task before it, and `forTasks` would leave it unbound.
+ */
fun ConfigurableLauncher<*>.configureFrom(
clientConfig: ClientGradleBuildConfig? = null,
buildParams: GradleBuildParams? = null,
+ tasks: List = emptyList(),
) {
logger.debug(
"configuring build launcher: hasClientConfig={}, hasBuildParams: {}",
@@ -42,6 +48,8 @@ fun ConfigurableLauncher<*>.configureFrom(
addJvmArguments(clientJvmArgs)
}
+ addArguments(tasks.filter(String::isNotBlank))
+
if (buildParams != null) {
val gradleArgs = buildParams.gradleArgs.filter(String::isNotBlank)
logger.debug("Build Gradle args: {}", gradleArgs)
diff --git a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExtsTest.kt b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExtsTest.kt
new file mode 100644
index 0000000000..e38cb36a2c
--- /dev/null
+++ b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/util/GradleBuildExtsTest.kt
@@ -0,0 +1,69 @@
+package com.itsaky.androidide.tooling.impl.util
+
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.tooling.api.messages.ClientGradleBuildConfig
+import com.itsaky.androidide.tooling.api.messages.GradleBuildParams
+import io.mockk.every
+import io.mockk.mockk
+import org.gradle.tooling.BuildLauncher
+import org.gradle.tooling.events.OperationType
+import org.gradle.tooling.events.ProgressListener
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+/**
+ * The argument order [configureFrom] gives a launcher. A task option such as `--tests` only binds
+ * to a task named before it in the arguments; with the task in `forTasks` instead, Gradle rejects
+ * it as "Unknown command-line option '--tests'" (ADFA-6337).
+ */
+@RunWith(JUnit4::class)
+class GradleBuildExtsTest {
+ private val arguments = mutableListOf()
+
+ private val launcher =
+ mockk(relaxed = true).also { launcher ->
+ // A relaxed mock answers the generic setters with the wrong type; they return the launcher.
+ every { launcher.setStandardError(any()) } returns launcher
+ every { launcher.setStandardOutput(any()) } returns launcher
+ every { launcher.setStandardInput(any()) } returns launcher
+ every {
+ launcher.addProgressListener(any(), any>())
+ } returns launcher
+ every { launcher.addJvmArguments(any>()) } returns launcher
+ every { launcher.addArguments(any>()) } answers {
+ arguments += firstArg>()
+ launcher
+ }
+ }
+
+ @Test
+ fun givenTasksAndATaskOption_whenConfiguring_thenTheTasksComeBetweenClientAndBuildArguments() {
+ launcher.configureFrom(
+ clientConfig = ClientGradleBuildConfig(GradleBuildParams(gradleArgs = listOf("--offline"))),
+ buildParams = GradleBuildParams(gradleArgs = listOf("--tests", "com.example.FooTest")),
+ tasks = listOf(":app:testDebugUnitTest"),
+ )
+
+ assertThat(arguments)
+ .containsExactly("--offline", ":app:testDebugUnitTest", "--tests", "com.example.FooTest")
+ .inOrder()
+ }
+
+ @Test
+ fun givenBlankTasks_whenConfiguring_thenTheyAreDropped() {
+ launcher.configureFrom(
+ buildParams = GradleBuildParams(gradleArgs = listOf("--info")),
+ tasks = listOf("build", " "),
+ )
+
+ assertThat(arguments).containsExactly("build", "--info").inOrder()
+ }
+
+ @Test
+ fun givenNoTasks_whenConfiguring_thenOnlyTheArgumentsArePassed() {
+ launcher.configureFrom(buildParams = GradleBuildParams(gradleArgs = listOf("-Pa=b")))
+
+ assertThat(arguments).containsExactly("-Pa=b")
+ }
+}
diff --git a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt
index 0582c448a5..8496eb94c3 100644
--- a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt
+++ b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt
@@ -52,5 +52,6 @@ data class TaskExecutionResult(
CONNECTION_CLOSED,
CACHE_READ_ERROR,
CACHE_WRITE_ERROR,
+ BUILD_IN_PROGRESS,
}
}
diff --git a/termux/termux-app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt b/termux/termux-app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt
index 66e1608bcc..c177a155ab 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,33 @@ 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
+ // TermuxActivity finishes a window with no sessions when its service connects before
+ // onStart; a session started now would run the command with nothing on screen.
+ if (isFinishing) {
+ request.notStarted("The Terminal closed before the command could start")
+ 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)
+ }
+}