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