From 87ab9092ef09fc600bd425f462105e60d6a83a77 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Fri, 3 Apr 2026 01:04:35 +0100 Subject: [PATCH 1/6] feat/ADFA-3580 Plugin Build Actions & Custom Scripts System MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class API for plugins to declare build actions (shell commands and Gradle tasks), execute them with streaming output to the Build Output panel, and control toolbar action visibility. Includes custom scripts support via .codeonthego/scripts.json — plugins can auto-detect project type (Node.js, Python, Rust, Go, Make, Ruby) and bootstrap user-editable run scripts on first project open. New plugin-api interfaces: BuildActionExtension, IdeCommandService, CommandExecution. New plugin-manager implementations: IdeCommandServiceImpl (ProcessBuilder-based with Termux env injection), PluginBuildActionManager (singleton orchestrator). New app integration: PluginBuildActionItem --- .../actions/build/PluginBuildActionItem.kt | 141 ++++++++++ .../extensions/BuildActionExtension.kt | 88 +++++++ .../plugins/services/IdeCommandService.kt | 20 ++ .../manager/build/PluginBuildActionManager.kt | 165 ++++++++++++ .../manager/services/IdeCommandServiceImpl.kt | 242 ++++++++++++++++++ 5 files changed, 656 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeCommandService.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt 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 new file mode 100644 index 0000000000..65c8e088a9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt @@ -0,0 +1,141 @@ +package com.itsaky.androidide.actions.build + +import android.content.Context +import android.graphics.ColorFilter +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +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.plugins.extensions.CommandOutput +import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager +import com.itsaky.androidide.plugins.manager.build.RegisteredBuildAction +import com.itsaky.androidide.plugins.manager.core.PluginManager +import com.itsaky.androidide.plugins.manager.ui.PluginDrawableResolver +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class PluginBuildActionItem( + 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 com.google.android.material.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() + + actionScope.launch { + 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) } + } + + return true + } + + private fun resetProgressIfIdle(activity: com.itsaky.androidide.activities.editor.EditorHandlerActivity) { + if (buildService?.isBuildInProgress != true) { + activity.editorViewModel.isBuildInProgress = false + } + activity.invalidateOptionsMenu() + } +} 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 new file mode 100644 index 0000000000..bd9d5f11a7 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt @@ -0,0 +1,88 @@ +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) {} +} + +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 + ) +} + +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 +) + +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() +} + +sealed class 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() +} + +enum class BuildActionCategory { BUILD, TEST, DEPLOY, LINT, CUSTOM } \ No newline at end of file diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeCommandService.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeCommandService.kt new file mode 100644 index 0000000000..ed4fb6a04f --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeCommandService.kt @@ -0,0 +1,20 @@ +package com.itsaky.androidide.plugins.services + +import com.itsaky.androidide.plugins.extensions.CommandOutput +import com.itsaky.androidide.plugins.extensions.CommandResult +import com.itsaky.androidide.plugins.extensions.CommandSpec +import kotlinx.coroutines.flow.Flow + +interface IdeCommandService { + fun executeCommand(spec: CommandSpec, timeoutMs: Long = 600_000): CommandExecution + fun isCommandRunning(executionId: String): Boolean + fun cancelCommand(executionId: String): Boolean + fun getRunningCommandCount(): Int +} + +interface CommandExecution { + val executionId: String + val output: Flow + suspend fun await(): CommandResult + fun cancel() +} \ No newline at end of file diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt new file mode 100644 index 0000000000..6433ee5671 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt @@ -0,0 +1,165 @@ +package com.itsaky.androidide.plugins.manager.build + +import com.itsaky.androidide.plugins.extensions.BuildActionCategory +import com.itsaky.androidide.plugins.extensions.BuildActionExtension +import com.itsaky.androidide.plugins.extensions.CommandResult +import com.itsaky.androidide.plugins.extensions.CommandSpec +import com.itsaky.androidide.plugins.extensions.PluginBuildAction +import com.itsaky.androidide.plugins.extensions.ToolbarActionIds +import com.itsaky.androidide.plugins.manager.loaders.ManifestBuildAction +import com.itsaky.androidide.plugins.manager.loaders.PluginManifest +import com.itsaky.androidide.plugins.services.CommandExecution +import com.itsaky.androidide.plugins.services.IdeCommandService +import java.util.concurrent.ConcurrentHashMap + +class PluginBuildActionManager private constructor() { + + private val pluginExtensions = ConcurrentHashMap() + private val manifestActions = ConcurrentHashMap>() + private val pluginNames = ConcurrentHashMap() + private val activeExecutions = ConcurrentHashMap() + + companion object { + @Volatile + private var INSTANCE: PluginBuildActionManager? = null + + fun getInstance(): PluginBuildActionManager { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: PluginBuildActionManager().also { INSTANCE = it } + } + } + } + + fun registerPlugin(pluginId: String, pluginName: String, extension: BuildActionExtension) { + pluginExtensions[pluginId] = extension + pluginNames[pluginId] = pluginName + } + + fun registerManifestActions(pluginId: String, pluginName: String, manifest: PluginManifest) { + if (manifest.buildActions.isEmpty()) return + + pluginNames[pluginId] = pluginName + manifestActions[pluginId] = manifest.buildActions.map { it.toPluginBuildAction() } + } + + fun getAllBuildActions(): List { + val actions = mutableListOf() + + for ((pluginId, extension) in pluginExtensions) { + val name = pluginNames[pluginId] ?: pluginId + try { + extension.getBuildActions().forEach { action -> + actions.add(RegisteredBuildAction(pluginId, name, action)) + } + } catch (_: Throwable) {} + } + + for ((pluginId, pluginActions) in manifestActions) { + if (pluginExtensions.containsKey(pluginId)) continue + val name = pluginNames[pluginId] ?: pluginId + pluginActions.forEach { action -> + actions.add(RegisteredBuildAction(pluginId, name, action)) + } + } + + return actions + } + + fun getHiddenActionIds(): Set { + val hidden = mutableSetOf() + + for ((_, extension) in pluginExtensions) { + try { + val requested = extension.toolbarActionsToHide() + hidden.addAll(requested.intersect(ToolbarActionIds.ALL)) + } catch (_: Throwable) {} + } + + return hidden + } + + fun executeAction( + pluginId: String, + actionId: String, + commandService: IdeCommandService + ): CommandExecution? { + val action = findAction(pluginId, actionId) ?: return null + val extension = pluginExtensions[pluginId] + + extension?.onActionStarted(actionId) + + val execution = commandService.executeCommand(action.command, action.timeoutMs) + val executionKey = "$pluginId:$actionId" + activeExecutions[executionKey] = execution + + return execution + } + + fun notifyActionCompleted(pluginId: String, actionId: String, result: CommandResult) { + activeExecutions.remove("$pluginId:$actionId") + pluginExtensions[pluginId]?.onActionCompleted(actionId, result) + } + + fun isActionRunning(pluginId: String, actionId: String): Boolean { + return activeExecutions.containsKey("$pluginId:$actionId") + } + + fun cancelAction(pluginId: String, actionId: String): Boolean { + val key = "$pluginId:$actionId" + return activeExecutions.remove(key)?.let { + it.cancel() + true + } ?: false + } + + fun cleanupPlugin(pluginId: String) { + activeExecutions.entries.removeAll { it.key.startsWith("$pluginId:") } + pluginExtensions.remove(pluginId) + manifestActions.remove(pluginId) + pluginNames.remove(pluginId) + } + + private fun findAction(pluginId: String, actionId: String): PluginBuildAction? { + pluginExtensions[pluginId]?.let { ext -> + try { + return ext.getBuildActions().find { it.id == actionId } + } catch (_: Throwable) {} + } + + return manifestActions[pluginId]?.find { it.id == actionId } + } +} + +data class RegisteredBuildAction( + val pluginId: String, + val pluginName: String, + val action: PluginBuildAction +) + +private fun ManifestBuildAction.toPluginBuildAction(): PluginBuildAction { + val spec = when { + gradleTask != null -> CommandSpec.GradleTask(gradleTask, arguments) + command != null -> CommandSpec.ShellCommand( + executable = command, + arguments = arguments, + workingDirectory = workingDirectory, + environment = environment + ) + else -> throw IllegalArgumentException("ManifestBuildAction must have either 'command' or 'gradle_task'") + } + + val cat = try { + BuildActionCategory.valueOf(category.uppercase()) + } catch (_: IllegalArgumentException) { + BuildActionCategory.CUSTOM + } + + return PluginBuildAction( + id = id, + name = name, + description = description, + category = cat, + command = spec, + timeoutMs = timeoutMs + ) +} 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 new file mode 100644 index 0000000000..f2c2c2cfb6 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt @@ -0,0 +1,242 @@ +package com.itsaky.androidide.plugins.manager.services + +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.CommandExecution +import com.itsaky.androidide.plugins.services.IdeCommandService +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.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class IdeCommandServiceImpl( + private val pluginId: String, + private val permissions: Set, + private val projectRootProvider: () -> File?, + private val appFilesDir: File +) : IdeCommandService { + + private val runningCommands = ConcurrentHashMap() + + override fun executeCommand(spec: CommandSpec, timeoutMs: Long): CommandExecution { + requirePermission() + requireConcurrencyLimit() + + val executionId = "$pluginId-${UUID.randomUUID()}" + val projectRoot = projectRootProvider() + + val processBuilder = when (spec) { + is CommandSpec.ShellCommand -> { + val workDir = spec.workingDirectory?.let { File(it) } ?: projectRoot + validateWorkingDirectory(workDir) + ProcessBuilder(listOf(spec.executable) + spec.arguments).apply { + workDir?.let { directory(it) } + environment().putAll(spec.environment) + } + } + is CommandSpec.GradleTask -> { + val gradleWrapper = projectRoot?.let { File(it, "gradlew") } + ?: throw IllegalStateException("No project root available for Gradle task execution") + ProcessBuilder(listOf(gradleWrapper.absolutePath, spec.taskPath) + spec.arguments).apply { + directory(projectRoot) + } + } + } + + processBuilder.redirectErrorStream(false) + injectTermuxEnvironment(processBuilder) + + val execution = CommandExecutionImpl( + executionId = executionId, + processBuilder = processBuilder, + timeoutMs = timeoutMs + ) + runningCommands[executionId] = execution + execution.start { runningCommands.remove(executionId) } + return execution + } + + override fun isCommandRunning(executionId: String): Boolean { + return runningCommands[executionId]?.isRunning() == true + } + + override fun cancelCommand(executionId: String): Boolean { + return runningCommands[executionId]?.let { + it.cancel() + true + } ?: false + } + + override fun getRunningCommandCount(): Int = runningCommands.size + + fun cancelAllCommands() { + runningCommands.values.forEach { it.cancel() } + runningCommands.clear() + } + + private fun requirePermission() { + if (PluginPermission.SYSTEM_COMMANDS !in permissions) { + throw SecurityException( + "Plugin $pluginId does not have SYSTEM_COMMANDS permission" + ) + } + } + + private fun requireConcurrencyLimit() { + if (runningCommands.size >= MAX_CONCURRENT_COMMANDS) { + throw IllegalStateException( + "Plugin $pluginId has reached the maximum of $MAX_CONCURRENT_COMMANDS concurrent commands" + ) + } + } + + private fun validateWorkingDirectory(dir: File?) { + if (dir == null) return + val projectRoot = projectRootProvider() ?: return + val canonicalDir = dir.canonicalPath + val canonicalRoot = projectRoot.canonicalPath + if (!canonicalDir.startsWith(canonicalRoot)) { + throw SecurityException( + "Plugin $pluginId attempted to execute in directory outside project root: $canonicalDir" + ) + } + } + + private fun injectTermuxEnvironment(processBuilder: ProcessBuilder) { + val termuxBase = appFilesDir.absolutePath + val termuxBin = "$termuxBase/usr/bin" + val termuxLib = "$termuxBase/usr/lib" + val env = processBuilder.environment() + + val existingPath = env["PATH"] ?: "" + if (!existingPath.contains(termuxBin)) { + env["PATH"] = "$termuxBin:$existingPath" + } + + val existingLdPath = env["LD_LIBRARY_PATH"] ?: "" + if (!existingLdPath.contains(termuxLib)) { + env["LD_LIBRARY_PATH"] = "$termuxLib:$existingLdPath" + } + + env.putIfAbsent("HOME", "$termuxBase/home") + env.putIfAbsent("TMPDIR", "$termuxBase/usr/tmp") + env.putIfAbsent("LANG", "en_US.UTF-8") + env.putIfAbsent("PREFIX", "$termuxBase/usr") + } + + companion object { + private const val MAX_CONCURRENT_COMMANDS = 3 + } +} + +private class CommandExecutionImpl( + override val executionId: String, + private val processBuilder: ProcessBuilder, + private val timeoutMs: Long +) : CommandExecution { + + private val outputChannel = Channel(capacity = 256) + private val resultDeferred = CompletableDeferred() + private val scope = CoroutineScope(Dispatchers.IO + Job()) + private var process: Process? = null + private val stdoutBuilder = StringBuilder() + private val stderrBuilder = StringBuilder() + + override val output: Flow = outputChannel.receiveAsFlow() + + fun start(onComplete: () -> Unit) { + scope.launch { + val startTime = System.currentTimeMillis() + try { + withTimeout(timeoutMs) { + process = processBuilder.start() + val proc = process!! + + val stdoutJob = launch { readStream(proc, isStdErr = false) } + val stderrJob = launch { readStream(proc, isStdErr = true) } + + val exitCode = proc.waitFor() + stdoutJob.join() + stderrJob.join() + + outputChannel.send(CommandOutput.ExitCode(exitCode)) + outputChannel.close() + + val duration = System.currentTimeMillis() - startTime + val result = if (exitCode == 0) { + CommandResult.Success(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), duration) + } else { + CommandResult.Failure(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), null, duration) + } + resultDeferred.complete(result) + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + process?.destroyForcibly() + outputChannel.close() + val duration = System.currentTimeMillis() - startTime + resultDeferred.complete( + CommandResult.Failure(-1, stdoutBuilder.toString(), stderrBuilder.toString(), "Command timed out after ${timeoutMs}ms", duration) + ) + } catch (e: Exception) { + process?.destroyForcibly() + outputChannel.close() + val duration = System.currentTimeMillis() - startTime + if (resultDeferred.isActive) { + resultDeferred.complete( + CommandResult.Cancelled(stdoutBuilder.toString(), stderrBuilder.toString()) + ) + } + } finally { + onComplete() + } + } + } + + private suspend fun readStream(process: Process, isStdErr: Boolean) { + val stream = if (isStdErr) process.errorStream else process.inputStream + val builder = if (isStdErr) stderrBuilder else stdoutBuilder + BufferedReader(InputStreamReader(stream)).use { reader -> + var line = reader.readLine() + while (line != null) { + if (builder.length + line.length <= MAX_OUTPUT_BYTES) { + builder.appendLine(line) + } + val output = if (isStdErr) CommandOutput.StdErr(line) else CommandOutput.StdOut(line) + outputChannel.send(output) + line = reader.readLine() + } + } + } + + override suspend fun await(): CommandResult = resultDeferred.await() + + override fun cancel() { + process?.destroyForcibly() + outputChannel.close() + if (resultDeferred.isActive) { + resultDeferred.complete( + CommandResult.Cancelled(stdoutBuilder.toString(), stderrBuilder.toString()) + ) + } + scope.cancel() + } + + fun isRunning(): Boolean = process?.isAlive == true + + companion object { + private const val MAX_OUTPUT_BYTES = 10 * 1024 * 1024 + } +} \ No newline at end of file From e64ffeabe5da4def67a025fee0c49466621670cd Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Fri, 3 Apr 2026 01:04:51 +0100 Subject: [PATCH 2/6] feat/ADFA-3580 Plugin Build Actions & Custom Scripts System MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class API for plugins to declare build actions (shell commands and Gradle tasks), execute them with streaming output to the Build Output panel, and control toolbar action visibility. Includes custom scripts support via .codeonthego/scripts.json — plugins can auto-detect project type (Node.js, Python, Rust, Go, Make, Ruby) and bootstrap user-editable run scripts on first project open. New plugin-api interfaces: BuildActionExtension, IdeCommandService, CommandExecution. New plugin-manager implementations: IdeCommandServiceImpl (ProcessBuilder-based with Termux env injection), PluginBuildActionManager (singleton orchestrator). New app integration: PluginBuildActionItem --- .../actions/EditorActivityAction.kt | 1 + .../editor/EditorHandlerActivity.kt | 4 ++ .../androidide/utils/EditorActivityActions.kt | 31 +++++++++-- plugin-api/build.gradle.kts | 2 + .../plugins/manager/core/PluginManager.kt | 51 ++++++++++++++++++- .../plugins/manager/loaders/PluginManifest.kt | 25 +++++++-- .../manager/ui/PluginDrawableResolver.kt | 11 ++-- 7 files changed, 113 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/actions/EditorActivityAction.kt b/app/src/main/java/com/itsaky/androidide/actions/EditorActivityAction.kt index 43678a372b..6708527f7a 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/EditorActivityAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/EditorActivityAction.kt @@ -44,6 +44,7 @@ abstract class EditorActivityAction : ActionItem { super.prepare(data) if (!data.hasRequiredData(Context::class.java)) { markInvisible() + return } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 6c6315cfae..ca787b73a9 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -63,6 +63,7 @@ import com.itsaky.androidide.models.OpenedFile import com.itsaky.androidide.models.OpenedFilesCache import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SaveResult +import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory import com.itsaky.androidide.plugins.manager.ui.PluginDrawableResolver import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager @@ -410,12 +411,15 @@ open class EditorHandlerActivity : content.projectActionsToolbar.clearMenu() val actions = getInstance().getActions(EDITOR_TOOLBAR) + val hiddenIds = PluginBuildActionManager.getInstance().getHiddenActionIds() actions.onEachIndexed { index, entry -> val action = entry.value val isLast = index == actions.size - 1 action.prepare(data) + if (action.id in hiddenIds || !action.visible) return@onEachIndexed + action.icon?.apply { colorFilter = action.createColorFilter(data) alpha = if (action.enabled) 255 else 76 diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt index 8711300e71..8cad0bcbdd 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt @@ -57,10 +57,13 @@ import com.itsaky.androidide.actions.filetree.RenameAction import com.itsaky.androidide.actions.text.RedoAction import com.itsaky.androidide.actions.text.UndoAction import com.itsaky.androidide.actions.PluginActionItem +import com.itsaky.androidide.actions.build.PluginBuildActionItem import com.itsaky.androidide.actions.etc.GenerateXMLAction import com.itsaky.androidide.plugins.extensions.UIExtension +import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.plugins.manager.core.PluginManager + /** * Takes care of registering actions to the actions registry for the editor activity. * @@ -104,6 +107,7 @@ class EditorActivityActions { // Plugin contributions order = registerPluginActions(context, registry, order) + order = registerPluginBuildActions(context, registry, order) // editor text actions registry.registerAction(ExpandSelectionAction(context, order++)) @@ -157,7 +161,8 @@ class EditorActivityActions { registry.clearActionsExceptWhere(EDITOR_TOOLBAR) { action -> action.id == QuickRunAction.ID || action.id == RunTasksAction.ID || - action.id == ProjectSyncAction.ID + action.id == ProjectSyncAction.ID || + action.id.startsWith("plugin.build.") } } @@ -184,14 +189,30 @@ class EditorActivityActions { val action = PluginActionItem(context, menuItem, order++) registry.registerAction(action) } - } catch (e: Exception) { - // Continue with other plugins if one fails - System.err.println("") - Log.d("plugin_debug", "Failed to register menu items for plugin: ${plugin.javaClass.simpleName} - ${e.message}") + } catch (_: Exception) { } } return order } + + @JvmStatic + private fun registerPluginBuildActions(context: Context, registry: ActionsRegistry, startOrder: Int): Int { + var order = startOrder + + val buildActions = PluginBuildActionManager.getInstance().getAllBuildActions() + for (registered in buildActions) { + try { + val action = PluginBuildActionItem(context, registered, order++) + registry.registerAction(action) + Log.d("plugin_debug", "Registered build action: ${registered.action.id} from plugin: ${registered.pluginId}") + } catch (e: Exception) { + Log.d("plugin_debug", "Failed to register build action: ${registered.action.id} - ${e.message}") + } + } + + return order + } + } } diff --git a/plugin-api/build.gradle.kts b/plugin-api/build.gradle.kts index 7adab325d9..c3a680df44 100644 --- a/plugin-api/build.gradle.kts +++ b/plugin-api/build.gradle.kts @@ -29,6 +29,8 @@ dependencies { compileOnly("androidx.appcompat:appcompat:1.6.1") compileOnly("androidx.fragment:fragment-ktx:1.6.2") compileOnly("com.google.android.material:material:1.11.0") + + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") } tasks.register("createPluginApiJar") { 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 f903da11f3..ad980e4ec4 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,6 +43,10 @@ import com.itsaky.androidide.plugins.manager.services.IdeThemeServiceImpl import com.itsaky.androidide.plugins.services.IdeThemeService import com.itsaky.androidide.plugins.services.IdeFeatureFlagService import com.itsaky.androidide.plugins.manager.services.IdeFeatureFlagServiceImpl +import com.itsaky.androidide.plugins.services.IdeCommandService +import com.itsaky.androidide.plugins.manager.services.IdeCommandServiceImpl +import com.itsaky.androidide.plugins.extensions.BuildActionExtension +import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.actions.SidebarSlotManager import com.itsaky.androidide.actions.SidebarSlotExceededException import kotlinx.coroutines.CoroutineScope @@ -424,6 +428,13 @@ class PluginManager private constructor( } } } + + val buildActionManager = PluginBuildActionManager.getInstance() + if (plugin is BuildActionExtension) { + buildActionManager.registerPlugin(manifest.id, manifest.name, plugin) + logger.info("Registered build actions for plugin: ${manifest.id}") + } + buildActionManager.registerManifestActions(manifest.id, manifest.name, manifest) } catch (e: Exception) { logger.error("Failed to activate plugin: ${manifest.id}", e) loadedPlugin.isEnabled = false @@ -472,6 +483,12 @@ class PluginManager private constructor( PluginProjectManager.getInstance().cleanupPluginTemplates(pluginId) + PluginBuildActionManager.getInstance().cleanupPlugin(pluginId) + val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java) + if (commandService is IdeCommandServiceImpl) { + commandService.cancelAllCommands() + } + val templateService = loadedPlugin.context.services.get(IdeTemplateService::class.java) if (templateService is IdeTemplateServiceImpl) { templateService.cleanupAllTemplates() @@ -599,7 +616,11 @@ class PluginManager private constructor( .filter { it.isEnabled } .map { it.plugin } } - + + fun getLoadedPlugin(pluginId: String): LoadedPlugin? { + return loadedPlugins[pluginId]?.takeIf { it.isEnabled } + } + /** * Get all enabled plugins that implement UI extensions */ @@ -942,6 +963,20 @@ class PluginManager private constructor( ) } + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeCommandService::class.java, + pluginId, + "command" + ) { + IdeCommandServiceImpl( + pluginId = pluginId, + permissions = permissions, + projectRootProvider = { projectProvider.getCurrentProject()?.rootDir }, + appFilesDir = context.filesDir + ) + } + // Create PluginContext with resource context return PluginContextImpl( androidContext = resourceContext, // Use the resource context instead of app context @@ -1087,6 +1122,20 @@ class PluginManager private constructor( ) } + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeCommandService::class.java, + pluginId, + "command" + ) { + IdeCommandServiceImpl( + pluginId = pluginId, + permissions = permissions, + projectRootProvider = { projectProvider.getCurrentProject()?.rootDir }, + appFilesDir = context.filesDir + ) + } + return PluginContextImpl( androidContext = context, services = pluginServiceRegistry, diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt index 9a4ccca1b8..83334f83f2 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt @@ -41,20 +41,39 @@ data class PluginManifest( val extensions: List = emptyList(), @SerializedName("sidebar_items") - val sidebarItems: Int = 0 + val sidebarItems: Int = 0, + + @SerializedName("build_actions") + val buildActions: List = emptyList() ) data class ExtensionInfo( @SerializedName("type") val type: String, - + @SerializedName("class") val className: String, - + @SerializedName("priority") val priority: Int = 0 ) +data class ManifestBuildAction( + val id: String, + val name: String, + val description: String = "", + val category: String = "CUSTOM", + val command: String? = null, + val arguments: List = emptyList(), + @SerializedName("gradle_task") + val gradleTask: String? = null, + @SerializedName("working_directory") + val workingDirectory: String? = null, + val environment: Map = emptyMap(), + @SerializedName("timeout_ms") + val timeoutMs: Long = 600_000 +) + object PluginManifestParser { private val gson = Gson() diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt index 03f925e5c1..cd583b4095 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt @@ -11,10 +11,15 @@ object PluginDrawableResolver { fun resolve(resId: Int, pluginId: String?, fallbackContext: Context): Drawable? { if (pluginId != null) { val pluginContext = PluginFragmentHelper.getPluginContext(pluginId) - ?: return loadDrawable(fallbackContext, resId) + if (pluginContext == null) { + return loadDrawable(fallbackContext, resId) + } try { - return ContextCompat.getDrawable(pluginContext, resId) - } catch (_: Resources.NotFoundException) { } + val drawable = ContextCompat.getDrawable(pluginContext, resId) + return drawable + } catch (_: Resources.NotFoundException) { + } catch (_: Throwable) { + } } return loadDrawable(fallbackContext, resId) } From d90259c9bbf7c40a70e072f7626959a3f9afeeb4 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Tue, 7 Apr 2026 11:21:01 +0100 Subject: [PATCH 3/6] fix: address CodeRabbit review findings for plugin build actions --- .../actions/build/PluginBuildActionItem.kt | 40 +++++++++----- .../androidide/utils/EditorActivityActions.kt | 3 +- .../manager/build/PluginBuildActionManager.kt | 55 ++++++++++++------- .../plugins/manager/loaders/PluginManifest.kt | 7 +++ .../manager/services/IdeCommandServiceImpl.kt | 47 ++++++++++------ .../manager/ui/PluginDrawableResolver.kt | 15 +++-- 6 files changed, 108 insertions(+), 59 deletions(-) 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 65c8e088a9..17a9b85bf8 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 @@ -20,6 +20,8 @@ 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 kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -109,24 +111,32 @@ class PluginBuildActionItem( activity.appendBuildOutput("━━━ ${registered.action.name} ━━━") activity.invalidateOptionsMenu() - actionScope.launch { - 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) + 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) } + 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 diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt index 8cad0bcbdd..1fa67a61fc 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt @@ -189,7 +189,8 @@ class EditorActivityActions { val action = PluginActionItem(context, menuItem, order++) registry.registerAction(action) } - } catch (_: Exception) { + } catch (e: Exception) { + Log.w("plugin_debug", "Failed to register menu items for plugin: ${plugin.javaClass.simpleName}", e) } } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt index 6433ee5671..21cbe5a44d 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt @@ -10,6 +10,7 @@ import com.itsaky.androidide.plugins.manager.loaders.ManifestBuildAction import com.itsaky.androidide.plugins.manager.loaders.PluginManifest import com.itsaky.androidide.plugins.services.CommandExecution import com.itsaky.androidide.plugins.services.IdeCommandService +import android.util.Log import java.util.concurrent.ConcurrentHashMap class PluginBuildActionManager private constructor() { @@ -20,6 +21,8 @@ class PluginBuildActionManager private constructor() { private val activeExecutions = ConcurrentHashMap() companion object { + private const val TAG = "PluginBuildActionManager" + @Volatile private var INSTANCE: PluginBuildActionManager? = null @@ -47,11 +50,13 @@ class PluginBuildActionManager private constructor() { for ((pluginId, extension) in pluginExtensions) { val name = pluginNames[pluginId] ?: pluginId - try { + runCatching { extension.getBuildActions().forEach { action -> actions.add(RegisteredBuildAction(pluginId, name, action)) } - } catch (_: Throwable) {} + }.onFailure { e -> + Log.w(TAG, "Failed to get build actions from plugin $pluginId", e) + } } for ((pluginId, pluginActions) in manifestActions) { @@ -69,10 +74,12 @@ class PluginBuildActionManager private constructor() { val hidden = mutableSetOf() for ((_, extension) in pluginExtensions) { - try { + runCatching { val requested = extension.toolbarActionsToHide() hidden.addAll(requested.intersect(ToolbarActionIds.ALL)) - } catch (_: Throwable) {} + }.onFailure { e -> + Log.w(TAG, "Failed to get hidden action ids from plugin", e) + } } return hidden @@ -88,24 +95,26 @@ class PluginBuildActionManager private constructor() { extension?.onActionStarted(actionId) - val execution = commandService.executeCommand(action.command, action.timeoutMs) - val executionKey = "$pluginId:$actionId" - activeExecutions[executionKey] = execution - - return execution + return runCatching { + commandService.executeCommand(action.command, action.timeoutMs) + }.onSuccess { execution -> + activeExecutions[executionKey(pluginId, actionId)] = execution + }.onFailure { e -> + extension?.onActionCompleted(actionId, CommandResult.Failure(-1, "", "", e.message, 0)) + }.getOrThrow() } fun notifyActionCompleted(pluginId: String, actionId: String, result: CommandResult) { - activeExecutions.remove("$pluginId:$actionId") + activeExecutions.remove(executionKey(pluginId, actionId)) pluginExtensions[pluginId]?.onActionCompleted(actionId, result) } fun isActionRunning(pluginId: String, actionId: String): Boolean { - return activeExecutions.containsKey("$pluginId:$actionId") + return activeExecutions.containsKey(executionKey(pluginId, actionId)) } fun cancelAction(pluginId: String, actionId: String): Boolean { - val key = "$pluginId:$actionId" + val key = executionKey(pluginId, actionId) return activeExecutions.remove(key)?.let { it.cancel() true @@ -113,20 +122,28 @@ class PluginBuildActionManager private constructor() { } fun cleanupPlugin(pluginId: String) { - activeExecutions.entries.removeAll { it.key.startsWith("$pluginId:") } + activeExecutions.entries.removeAll { (key, execution) -> + if (key.startsWith("$pluginId:")) { + execution.cancel() + true + } else false + } pluginExtensions.remove(pluginId) manifestActions.remove(pluginId) pluginNames.remove(pluginId) } + private fun executionKey(pluginId: String, actionId: String) = "$pluginId:$actionId" + private fun findAction(pluginId: String, actionId: String): PluginBuildAction? { - pluginExtensions[pluginId]?.let { ext -> - try { - return ext.getBuildActions().find { it.id == actionId } - } catch (_: Throwable) {} + val fromExtension = pluginExtensions[pluginId]?.let { ext -> + runCatching { + ext.getBuildActions().find { it.id == actionId } + }.onFailure { e -> + Log.w(TAG, "Failed to find action $actionId in plugin $pluginId", e) + }.getOrNull() } - - return manifestActions[pluginId]?.find { it.id == actionId } + return fromExtension ?: manifestActions[pluginId]?.find { it.id == actionId } } } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt index 83334f83f2..cdffedc7af 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt @@ -59,16 +59,23 @@ data class ExtensionInfo( ) data class ManifestBuildAction( + @SerializedName("id") val id: String, + @SerializedName("name") val name: String, + @SerializedName("description") val description: String = "", + @SerializedName("category") val category: String = "CUSTOM", + @SerializedName("command") val command: String? = null, + @SerializedName("arguments") val arguments: List = emptyList(), @SerializedName("gradle_task") val gradleTask: String? = null, @SerializedName("working_directory") val workingDirectory: String? = null, + @SerializedName("environment") val environment: Map = emptyMap(), @SerializedName("timeout_ms") val timeoutMs: Long = 600_000 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 f2c2c2cfb6..9f8348bf1d 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 @@ -50,6 +50,12 @@ class IdeCommandServiceImpl( 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) } @@ -83,8 +89,10 @@ class IdeCommandServiceImpl( override fun getRunningCommandCount(): Int = runningCommands.size fun cancelAllCommands() { - runningCommands.values.forEach { it.cancel() } - runningCommands.clear() + runningCommands.entries.removeAll { (_, execution) -> + execution.cancel() + true + } } private fun requirePermission() { @@ -160,7 +168,8 @@ private class CommandExecutionImpl( fun start(onComplete: () -> Unit) { scope.launch { val startTime = System.currentTimeMillis() - try { + + runCatching { withTimeout(timeoutMs) { process = processBuilder.start() val proc = process!! @@ -176,32 +185,34 @@ private class CommandExecutionImpl( outputChannel.close() val duration = System.currentTimeMillis() - startTime - val result = if (exitCode == 0) { + if (exitCode == 0) { CommandResult.Success(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), duration) } else { CommandResult.Failure(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), null, duration) } - resultDeferred.complete(result) } - } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - process?.destroyForcibly() - outputChannel.close() - val duration = System.currentTimeMillis() - startTime - resultDeferred.complete( - CommandResult.Failure(-1, stdoutBuilder.toString(), stderrBuilder.toString(), "Command timed out after ${timeoutMs}ms", duration) - ) - } catch (e: Exception) { + }.onSuccess { result -> + resultDeferred.complete(result) + }.onFailure { e -> process?.destroyForcibly() outputChannel.close() + val stdout = stdoutBuilder.toString() + val stderr = stderrBuilder.toString() val duration = System.currentTimeMillis() - startTime + val failureResult = when (e) { + is kotlinx.coroutines.TimeoutCancellationException -> + CommandResult.Failure(-1, stdout, stderr, "Command timed out after ${timeoutMs}ms: ${e.message}", duration) + is kotlinx.coroutines.CancellationException -> + CommandResult.Cancelled(stdout, stderr) + else -> + CommandResult.Failure(-1, stdout, stderr, "Unexpected error: ${e.message}", duration) + } if (resultDeferred.isActive) { - resultDeferred.complete( - CommandResult.Cancelled(stdoutBuilder.toString(), stderrBuilder.toString()) - ) + resultDeferred.complete(failureResult) } - } finally { - onComplete() } + + onComplete() } } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt index cd583b4095..8eaca68cd6 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.plugins.manager.ui import android.content.Context import android.content.res.Resources import android.graphics.drawable.Drawable +import android.util.Log import androidx.core.content.ContextCompat import com.itsaky.androidide.plugins.base.PluginFragmentHelper @@ -14,12 +15,14 @@ object PluginDrawableResolver { if (pluginContext == null) { return loadDrawable(fallbackContext, resId) } - try { - val drawable = ContextCompat.getDrawable(pluginContext, resId) - return drawable - } catch (_: Resources.NotFoundException) { - } catch (_: Throwable) { - } + val drawable = runCatching { + ContextCompat.getDrawable(pluginContext, resId) + }.onFailure { e -> + if (e !is Resources.NotFoundException && e !is IllegalArgumentException) { + Log.w("PluginDrawableResolver", "Failed to resolve drawable $resId for plugin $pluginId", e) + } + }.getOrNull() + if (drawable != null) return drawable } return loadDrawable(fallbackContext, resId) } From a128f2aade9c0995676b03a81193d656e9ac871e Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Tue, 7 Apr 2026 11:47:39 +0100 Subject: [PATCH 4/6] fixes --- .../actions/build/PluginBuildActionItem.kt | 3 +- .../manager/build/PluginBuildActionManager.kt | 2 + .../plugins/manager/loaders/PluginManifest.kt | 40 ++++++++++++++++--- .../manager/services/IdeCommandServiceImpl.kt | 18 ++++++--- 4 files changed, 50 insertions(+), 13 deletions(-) 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 17a9b85bf8..705edfa330 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 @@ -143,7 +143,8 @@ class PluginBuildActionItem( } private fun resetProgressIfIdle(activity: com.itsaky.androidide.activities.editor.EditorHandlerActivity) { - if (buildService?.isBuildInProgress != true) { + val manager = PluginBuildActionManager.getInstance() + if (buildService?.isBuildInProgress != true && !manager.hasActiveExecutions()) { activity.editorViewModel.isBuildInProgress = false } activity.invalidateOptionsMenu() diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt index 21cbe5a44d..e212e6a622 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt @@ -113,6 +113,8 @@ class PluginBuildActionManager private constructor() { return activeExecutions.containsKey(executionKey(pluginId, actionId)) } + fun hasActiveExecutions(): Boolean = activeExecutions.isNotEmpty() + fun cancelAction(pluginId: String, actionId: String): Boolean { val key = executionKey(pluginId, actionId) return activeExecutions.remove(key)?.let { diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt index cdffedc7af..bb665ee317 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt @@ -83,32 +83,60 @@ data class ManifestBuildAction( object PluginManifestParser { private val gson = Gson() - + fun parseFromJar(jarFile: File): PluginManifest? { return try { JarFile(jarFile).use { jar -> val entry = jar.getJarEntry("plugin.json") ?: jar.getJarEntry("META-INF/plugin.json") ?: return null - + val inputStream = jar.getInputStream(entry) val reader = InputStreamReader(inputStream) - gson.fromJson(reader, PluginManifest::class.java) + gson.fromJson(reader, PluginManifest::class.java)?.normalize() } } catch (e: Exception) { null } } - + fun parseFromString(json: String): PluginManifest? { return try { - gson.fromJson(json, PluginManifest::class.java) + gson.fromJson(json, PluginManifest::class.java)?.normalize() } catch (e: Exception) { null } } - + fun toJson(manifest: PluginManifest): String { return gson.toJson(manifest) } + + @Suppress("SENSELESS_COMPARISON") + private fun PluginManifest.normalize(): PluginManifest { + val normalizedActions = (buildActions ?: emptyList()).map { it.normalize() } + return if ( + permissions == null || dependencies == null || extensions == null || buildActions == null || + normalizedActions !== buildActions + ) { + copy( + permissions = permissions ?: emptyList(), + dependencies = dependencies ?: emptyList(), + extensions = extensions ?: emptyList(), + buildActions = normalizedActions + ) + } else this + } + + @Suppress("SENSELESS_COMPARISON") + private fun ManifestBuildAction.normalize(): ManifestBuildAction { + if (arguments == null || environment == null || timeoutMs == 0L) { + return copy( + arguments = arguments ?: emptyList(), + environment = environment ?: emptyMap(), + timeoutMs = if (timeoutMs == 0L) 600_000 else timeoutMs + ) + } + return this + } } \ No newline at end of file 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 9f8348bf1d..3d8cbbb9ff 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 @@ -19,6 +19,8 @@ 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 @@ -40,7 +42,11 @@ class IdeCommandServiceImpl( val processBuilder = when (spec) { is CommandSpec.ShellCommand -> { - val workDir = spec.workingDirectory?.let { File(it) } ?: projectRoot + 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) } @@ -114,11 +120,11 @@ class IdeCommandServiceImpl( private fun validateWorkingDirectory(dir: File?) { if (dir == null) return val projectRoot = projectRootProvider() ?: return - val canonicalDir = dir.canonicalPath - val canonicalRoot = projectRoot.canonicalPath - if (!canonicalDir.startsWith(canonicalRoot)) { + 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: $canonicalDir" + "Plugin $pluginId attempted to execute in directory outside project root: $normalizedDir" ) } } @@ -156,7 +162,7 @@ private class CommandExecutionImpl( private val timeoutMs: Long ) : CommandExecution { - private val outputChannel = Channel(capacity = 256) + private val outputChannel = Channel(capacity = Channel.UNLIMITED) private val resultDeferred = CompletableDeferred() private val scope = CoroutineScope(Dispatchers.IO + Job()) private var process: Process? = null From 307892c0c6593f048ea1f70c1d39a1e8612ea8ff Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Tue, 7 Apr 2026 15:14:08 +0100 Subject: [PATCH 5/6] fix: remove dead createPluginContext duplicate from PluginManager --- .../plugins/manager/core/PluginManager.kt | 157 ------------------ 1 file changed, 157 deletions(-) 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 ad980e4ec4..ef64127e4b 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 @@ -988,163 +988,6 @@ class PluginManager private constructor( ) } - private fun createPluginContext( - pluginId: String, - classLoader: ClassLoader, - permissions: Set - ): PluginContext { - // Create a plugin-specific service registry with permission-validated services - val pluginServiceRegistry = ServiceRegistryImpl() - - logger.debug("Creating IDE services for plugin: $pluginId") - - // Only create services if providers are available, otherwise plugins will get null services - // This prevents crashes but plugins should handle null service gracefully - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeProjectService::class.java, - pluginId, - "project" - ) { - IdeProjectServiceImpl( - pluginId = pluginId, - permissions = permissions, - projectProvider = projectProvider, - requiredPermissions = projectServicePermissions, - pathValidator = pathValidator?.let { validator -> - object : IdeProjectServiceImpl.PathValidator { - override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) - override fun getAllowedPaths(): List = validator.getAllowedPaths() - } - } - ) - } - - - // UI service is always created, even if activityProvider is null - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeUIService::class.java, - pluginId, - "UI" - ) { - IdeUIServiceImpl(activityProvider) - } - - // Build service is always created to provide build status information - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeBuildService::class.java, - pluginId, - "build" - ) { - IdeBuildServiceImpl.getInstance() - } - - // Tooltip service for showing documentation tooltips - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeTooltipService::class.java, - pluginId, - "tooltip" - ) { - IdeTooltipServiceImpl(context, pluginId, activityProvider) - } - - // Editor tab service for plugin editor tab integration - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeEditorTabService::class.java, - pluginId, - "editor_tab" - ) { - IdeEditorTabServiceImpl(activityProvider) - } - - // File service for editing project files - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeFileService::class.java, - pluginId, - "file" - ) { - IdeFileServiceImpl( - pluginId = pluginId, - permissions = permissions, - pathValidator = pathValidator?.let { validator -> - object : IdeFileServiceImpl.PathValidator { - override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) - override fun getAllowedPaths(): List = validator.getAllowedPaths() - } - } - ) - } - - // Sidebar service for plugin sidebar slot management - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeSidebarService::class.java, - pluginId, - "sidebar" - ) { - IdeSidebarServiceImpl(pluginId) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeThemeService::class.java, - pluginId, - "theme" - ) { - IdeThemeServiceImpl(context) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeFeatureFlagService::class.java, - pluginId, - "feature_flag" - ) { - IdeFeatureFlagServiceImpl() - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeTemplateService::class.java, - pluginId, - "template" - ) { - IdeTemplateServiceImpl( - pluginId = pluginId, - permissions = permissions, - onTemplatesChanged = { templateReloadListener?.invoke() } - ) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeCommandService::class.java, - pluginId, - "command" - ) { - IdeCommandServiceImpl( - pluginId = pluginId, - permissions = permissions, - projectRootProvider = { projectProvider.getCurrentProject()?.rootDir }, - appFilesDir = context.filesDir - ) - } - - return PluginContextImpl( - androidContext = context, - services = pluginServiceRegistry, - eventBus = eventBus, - logger = PluginLoggerImpl(pluginId, logger), - resources = ResourceManagerImpl(pluginId, pluginsDir, classLoader), - pluginId = pluginId - ) - } /** * Clean up ALL plugin files and cache directories From 4468c376c48a43278750414dc15dec9c0bb2a3c7 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 8 Apr 2026 09:43:55 +0100 Subject: [PATCH 6/6] fixes --- .../actions/build/PluginBuildActionItem.kt | 5 +++-- .../itsaky/androidide/utils/EditorActivityActions.kt | 12 +++++------- 2 files changed, 8 insertions(+), 9 deletions(-) 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 705edfa330..97d74eddf5 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 @@ -21,6 +21,7 @@ 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 @@ -67,7 +68,7 @@ class PluginBuildActionItem( val context = data.getContext() ?: return null val isRunning = PluginBuildActionManager.getInstance() .isActionRunning(registered.pluginId, registered.action.id) - val attr = if (isRunning) R.attr.colorError else com.google.android.material.R.attr.colorOnSurface + val attr = if (isRunning) R.attr.colorError else R.attr.colorOnSurface return PorterDuffColorFilter( context.resolveAttr(attr), PorterDuff.Mode.SRC_ATOP @@ -142,7 +143,7 @@ class PluginBuildActionItem( return true } - private fun resetProgressIfIdle(activity: com.itsaky.androidide.activities.editor.EditorHandlerActivity) { + private fun resetProgressIfIdle(activity: EditorHandlerActivity) { val manager = PluginBuildActionManager.getInstance() if (buildService?.isBuildInProgress != true && !manager.hasActiveExecutions()) { activity.editorViewModel.isBuildInProgress = false diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt index 1fa67a61fc..aad49bc32b 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt @@ -201,14 +201,12 @@ class EditorActivityActions { private fun registerPluginBuildActions(context: Context, registry: ActionsRegistry, startOrder: Int): Int { var order = startOrder - val buildActions = PluginBuildActionManager.getInstance().getAllBuildActions() - for (registered in buildActions) { - try { - val action = PluginBuildActionItem(context, registered, order++) - registry.registerAction(action) + PluginBuildActionManager.getInstance().getAllBuildActions().forEach { registered -> + runCatching { + registry.registerAction(PluginBuildActionItem(context, registered, order++)) Log.d("plugin_debug", "Registered build action: ${registered.action.id} from plugin: ${registered.pluginId}") - } catch (e: Exception) { - Log.d("plugin_debug", "Failed to register build action: ${registered.action.id} - ${e.message}") + }.onFailure { e -> + Log.w("plugin_debug", "Failed to register build action: ${registered.action.id}", e) } }