Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ abstract class EditorActivityAction : ActionItem {
super.prepare(data)
if (!data.hasRequiredData(Context::class.java)) {
markInvisible()
return
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Comment on lines +42 to +62
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing super.prepare(data) call.

BaseBuildAction.prepare() performs context validation and sets visible/enabled based on build service state. Not calling super.prepare(data) bypasses these checks. While you're handling visibility manually, the base class behavior should be considered.

🔧 Proposed fix
     override fun prepare(data: ActionData) {
+        super.prepare(data)
         val context = data.getActivity()
         if (context == null) {
             visible = false
             return
         }
         visible = true

Note: If you intentionally want to bypass BaseBuildAction's buildService.isBuildInProgress check (since plugin actions have their own running state), consider documenting this or extracting a different base class.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 prepare(data: ActionData) {
super.prepare(data)
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
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt`
around lines 42 - 62, The override of prepare in PluginBuildActionItem is
missing a call to the base implementation; call super.prepare(data) at the start
of PluginBuildActionItem.prepare so BaseBuildAction.prepare can perform its
context validation and set visible/enabled based on buildService state before
you apply plugin-specific logic (or if you intentionally want to bypass that
behavior, add a comment explaining the reason and consider extracting a
different base class rather than skipping super.prepare); keep the rest of the
method (using PluginBuildActionManager.isActionRunning and setting
label/icon/enabled) unchanged.


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) }
}
Comment on lines +112 to +130
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider lifecycle-safe coroutine handling.

The coroutine launched in actionScope may outlive the activity if the user navigates away during a long-running build. This could lead to crashes when withContext(Dispatchers.Main) tries to update a destroyed activity. Consider:

  1. Using activity.lifecycleScope instead of actionScope for automatic cancellation.
  2. Adding null-safety checks before UI updates.
  3. Wrapping the collect/await in try-catch for cancellation exceptions.
🛡️ Proposed lifecycle-safe pattern
-        actionScope.launch {
-            execution.output.collect { output ->
+        activity.lifecycleScope.launch {
+            try {
+                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.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)
+                resetProgressIfIdle(activity)
+            } catch (e: CancellationException) {
+                manager.cancelAction(pluginId, actionId)
+                throw e
+            }
         }

Note: Using lifecycleScope automatically runs on Main dispatcher and cancels when the activity is destroyed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt`
around lines 112 - 130, The coroutine launched on actionScope can outlive the
Activity and call withContext(Dispatchers.Main) after the Activity is destroyed;
change the launch to use the Activity lifecycle (replace actionScope.launch with
activity.lifecycleScope.launch or otherwise tie the Job to activity.lifecycle),
wrap the execution.output.collect and execution.await call in a try-catch that
handles CancellationException and other exceptions, and before calling
activity.appendBuildOutput(...) or resetProgressIfIdle(activity) check
activity.isFinishing/isDestroyed (or use lifecycleScope which cancels
automatically) so UI updates are only attempted when the Activity is alive; keep
manager.notifyActionCompleted(pluginId, actionId, result) behavior but ensure it
runs in a safe context if it touches UI.


return true
}

private fun resetProgressIfIdle(activity: com.itsaky.androidide.activities.editor.EditorHandlerActivity) {
if (buildService?.isBuildInProgress != true) {
activity.editorViewModel.isBuildInProgress = false
}
activity.invalidateOptionsMenu()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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++))
Expand Down Expand Up @@ -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.")
}
}

Expand All @@ -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
}

}
}
2 changes: 2 additions & 0 deletions plugin-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Copy>("createPluginApiJar") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.itsaky.androidide.plugins.extensions

import com.itsaky.androidide.plugins.IPlugin

interface BuildActionExtension : IPlugin {
fun getBuildActions(): List<PluginBuildAction>
fun toolbarActionsToHide(): Set<String> = 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<String> = 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<String> = emptyList(),
val workingDirectory: String? = null,
val environment: Map<String, String> = emptyMap()
) : CommandSpec()

data class GradleTask(
val taskPath: String,
val arguments: List<String> = 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 }
Original file line number Diff line number Diff line change
@@ -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<CommandOutput>
suspend fun await(): CommandResult
fun cancel()
}
Loading
Loading