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