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..97d74eddf5 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt @@ -0,0 +1,153 @@ +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 androidx.lifecycle.lifecycleScope +import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class PluginBuildActionItem( + context: Context, + private val registered: RegisteredBuildAction, + override val order: Int +) : BaseBuildAction() { + + override val id: String = "plugin.build.${registered.pluginId}.${registered.action.id}" + + init { + label = registered.action.name + icon = resolvePluginIcon(context) + location = ActionItem.Location.EDITOR_TOOLBAR + requiresUIThread = true + } + + override fun prepare(data: ActionData) { + val context = data.getActivity() + if (context == null) { + visible = false + return + } + visible = true + + val manager = PluginBuildActionManager.getInstance() + val isRunning = manager.isActionRunning(registered.pluginId, registered.action.id) + + if (isRunning) { + label = "Cancel ${registered.action.name}" + icon = ContextCompat.getDrawable(context, R.drawable.ic_stop) + enabled = true + } else { + label = registered.action.name + icon = resolvePluginIcon(context) + enabled = true + } + } + + override fun createColorFilter(data: ActionData): ColorFilter? { + val context = data.getContext() ?: return null + val isRunning = PluginBuildActionManager.getInstance() + .isActionRunning(registered.pluginId, registered.action.id) + val attr = if (isRunning) R.attr.colorError else R.attr.colorOnSurface + return PorterDuffColorFilter( + context.resolveAttr(attr), + PorterDuff.Mode.SRC_ATOP + ) + } + + private fun resolvePluginIcon(fallbackContext: Context): Drawable? { + val iconResId = registered.action.icon ?: return ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline) + return PluginDrawableResolver.resolve(iconResId, registered.pluginId, fallbackContext) + ?: ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline) + } + + override suspend fun execAction(data: ActionData): Any { + val manager = PluginBuildActionManager.getInstance() + val pluginId = registered.pluginId + val actionId = registered.action.id + + if (manager.isActionRunning(pluginId, actionId)) { + manager.cancelAction(pluginId, actionId) + data.getActivity()?.let { resetProgressIfIdle(it) } + return true + } + + val activity = data.getActivity() ?: return false + + val pluginManager = PluginManager.getInstance() ?: return false + val loadedPlugin = pluginManager.getLoadedPlugin(pluginId) ?: return false + val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java) + ?: return false + + val execution = manager.executeAction(pluginId, actionId, commandService) ?: return false + + activity.editorViewModel.isBuildInProgress = true + val currentSheetState = activity.bottomSheetViewModel.sheetBehaviorState + val targetState = if (currentSheetState == BottomSheetBehavior.STATE_HIDDEN) + BottomSheetBehavior.STATE_COLLAPSED else currentSheetState + activity.bottomSheetViewModel.setSheetState( + sheetState = targetState, + currentTab = BottomSheetViewModel.TAB_BUILD_OUTPUT + ) + activity.appendBuildOutput("━━━ ${registered.action.name} ━━━") + activity.invalidateOptionsMenu() + + activity.lifecycleScope.launch(Dispatchers.Default) { + runCatching { + execution.output.collect { output -> + val line = when (output) { + is CommandOutput.StdOut -> output.line + is CommandOutput.StdErr -> output.line + is CommandOutput.ExitCode -> + if (output.code != 0) "Process failed with code ${output.code}" else null + } + if (line != null) { + withContext(Dispatchers.Main) { + activity.appendBuildOutput(line) + } + } + } + + val result = execution.await() + manager.notifyActionCompleted(pluginId, actionId, result) + withContext(Dispatchers.Main) { resetProgressIfIdle(activity) } + }.onFailure { e -> + if (e is CancellationException) { + manager.cancelAction(pluginId, actionId) + throw e + } + withContext(Dispatchers.Main) { resetProgressIfIdle(activity) } + } + } + + return true + } + + private fun resetProgressIfIdle(activity: EditorHandlerActivity) { + val manager = PluginBuildActionManager.getInstance() + if (buildService?.isBuildInProgress != true && !manager.hasActiveExecutions()) { + activity.editorViewModel.isBuildInProgress = false + } + activity.invalidateOptionsMenu() + } +} 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 4b0b9d3400..1dd511880d 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 @@ -423,12 +424,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..aad49bc32b 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.") } } @@ -185,13 +190,28 @@ class EditorActivityActions { 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}") + Log.w("plugin_debug", "Failed to register menu items for plugin: ${plugin.javaClass.simpleName}", e) } } return order } + + @JvmStatic + private fun registerPluginBuildActions(context: Context, registry: ActionsRegistry, startOrder: Int): Int { + var order = startOrder + + 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}") + }.onFailure { e -> + Log.w("plugin_debug", "Failed to register build action: ${registered.action.id}", e) + } + } + + 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..e212e6a622 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt @@ -0,0 +1,184 @@ +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 android.util.Log +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 { + private const val TAG = "PluginBuildActionManager" + + @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 + runCatching { + extension.getBuildActions().forEach { action -> + actions.add(RegisteredBuildAction(pluginId, name, action)) + } + }.onFailure { e -> + Log.w(TAG, "Failed to get build actions from plugin $pluginId", e) + } + } + + 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) { + runCatching { + val requested = extension.toolbarActionsToHide() + hidden.addAll(requested.intersect(ToolbarActionIds.ALL)) + }.onFailure { e -> + Log.w(TAG, "Failed to get hidden action ids from plugin", e) + } + } + + 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) + + 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(executionKey(pluginId, actionId)) + pluginExtensions[pluginId]?.onActionCompleted(actionId, result) + } + + fun isActionRunning(pluginId: String, actionId: String): Boolean { + 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 { + it.cancel() + true + } ?: false + } + + fun cleanupPlugin(pluginId: String) { + 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? { + 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 fromExtension ?: 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..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 @@ -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,153 +963,23 @@ class PluginManager private constructor( ) } - // Create PluginContext with resource context - return PluginContextImpl( - androidContext = resourceContext, // Use the resource context instead of app context - services = pluginServiceRegistry, - eventBus = eventBus, - logger = PluginLoggerImpl(pluginId, logger), - resources = ResourceManagerImpl(pluginId, pluginsDir, classLoader), - pluginId = pluginId - ) - } - - 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, + IdeCommandService::class.java, pluginId, - "project" + "command" ) { - IdeProjectServiceImpl( + IdeCommandServiceImpl( 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() } + projectRootProvider = { projectProvider.getCurrentProject()?.rootDir }, + appFilesDir = context.filesDir ) } + // Create PluginContext with resource context return PluginContextImpl( - androidContext = context, + androidContext = resourceContext, // Use the resource context instead of app context services = pluginServiceRegistry, eventBus = eventBus, logger = PluginLoggerImpl(pluginId, logger), @@ -1097,6 +988,7 @@ class PluginManager private constructor( ) } + /** * Clean up ALL plugin files and cache directories */ 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..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 @@ -41,48 +41,102 @@ 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( + @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 +) + 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 new file mode 100644 index 0000000000..3d8cbbb9ff --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt @@ -0,0 +1,259 @@ +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.nio.file.Path +import java.nio.file.Paths +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 = when { + spec.workingDirectory == null -> projectRoot + Paths.get(spec.workingDirectory).isAbsolute -> File(spec.workingDirectory) + else -> projectRoot?.let { File(it, spec.workingDirectory).canonicalFile } + } + validateWorkingDirectory(workDir) + ProcessBuilder(listOf(spec.executable) + spec.arguments).apply { + workDir?.let { directory(it) } + environment().putAll(spec.environment) + } + } + 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) + } + } + } + + 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.entries.removeAll { (_, execution) -> + execution.cancel() + true + } + } + + 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 normalizedDir = dir.canonicalFile.toPath() + val normalizedRoot = projectRoot.canonicalFile.toPath() + if (normalizedDir != normalizedRoot && !normalizedDir.startsWith(normalizedRoot)) { + throw SecurityException( + "Plugin $pluginId attempted to execute in directory outside project root: $normalizedDir" + ) + } + } + + 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 = Channel.UNLIMITED) + 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() + + runCatching { + 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 + if (exitCode == 0) { + CommandResult.Success(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), duration) + } else { + CommandResult.Failure(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), null, duration) + } + } + }.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(failureResult) + } + } + + 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..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 @@ -11,10 +12,17 @@ object PluginDrawableResolver { fun resolve(resId: Int, pluginId: String?, fallbackContext: Context): Drawable? { if (pluginId != null) { val pluginContext = PluginFragmentHelper.getPluginContext(pluginId) - ?: return loadDrawable(fallbackContext, resId) - try { - return ContextCompat.getDrawable(pluginContext, resId) - } catch (_: Resources.NotFoundException) { } + if (pluginContext == null) { + return loadDrawable(fallbackContext, resId) + } + 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) }