diff --git a/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt b/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt
index 362a1a2452..5597f0ac0c 100644
--- a/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt
+++ b/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt
@@ -113,6 +113,15 @@ interface ActionItem {
val itemId: Int
get() = id.hashCode()
+ /**
+ * Whether the editor toolbar should fully remove this action when [visible] is false,
+ * instead of the legacy behaviour of keeping it and only greying out when disabled.
+ * Built-in actions keep the legacy behaviour (default false); plugin-contributed
+ * toolbar actions opt in by overriding this to true.
+ */
+ val honorVisibility: Boolean
+ get() = false
+
/**
* Prepare the action. Subclasses can modify the visual properties of this action here.
*
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e8e8ff0584..d524d954c6 100755
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -323,7 +323,6 @@ dependencies {
implementation(projects.layouteditor)
implementation(projects.idetooltips)
- implementation(projects.composePreview)
implementation(projects.gitCore)
// This is to build the tooling-api-impl project before the app is built
diff --git a/app/src/main/java/com/itsaky/androidide/actions/PluginToolbarActionItem.kt b/app/src/main/java/com/itsaky/androidide/actions/PluginToolbarActionItem.kt
new file mode 100644
index 0000000000..35f00ceb6c
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/actions/PluginToolbarActionItem.kt
@@ -0,0 +1,88 @@
+package com.itsaky.androidide.actions
+
+import android.content.Context
+import android.util.Log
+import android.view.MenuItem
+import androidx.core.content.ContextCompat
+import com.itsaky.androidide.R
+import com.itsaky.androidide.plugins.extensions.ShowAsAction
+import com.itsaky.androidide.plugins.extensions.ToolbarAction
+import com.itsaky.androidide.plugins.manager.pluginCategory
+import com.itsaky.androidide.plugins.manager.pluginTooltipTag
+import com.itsaky.androidide.plugins.manager.ui.PluginDrawableResolver
+
+/**
+ * Adapts a plugin-contributed [ToolbarAction] (from `UIExtension.getToolbarActions()`)
+ * into an editor-toolbar [ActionItem].
+ *
+ * Unlike [PluginActionItem] (which wraps `getMainMenuItems()`), this is the dedicated
+ * path for toolbar icons: it carries the action's own [ToolbarAction.order] so a plugin
+ * can position its icon among the built-in actions, and it opts into [honorVisibility]
+ * so the toolbar fully removes it (instead of greying it) when not applicable.
+ */
+class PluginToolbarActionItem(
+ context: Context,
+ private val toolbarAction: ToolbarAction,
+ val pluginId: String
+) : EditorActivityAction() {
+
+ override val id: String = "plugin.toolbar.${toolbarAction.id}"
+
+ override val order: Int = toolbarAction.order
+
+ override val honorVisibility: Boolean get() = true
+
+ init {
+ label = toolbarAction.title
+ val iconResId = toolbarAction.icon
+ icon = if (iconResId != null) {
+ PluginDrawableResolver.resolve(iconResId, pluginId, context)
+ ?: ContextCompat.getDrawable(context, R.drawable.ic_package)
+ } else {
+ ContextCompat.getDrawable(context, R.drawable.ic_package)
+ }
+ location = ActionItem.Location.EDITOR_TOOLBAR
+ requiresUIThread = true
+ }
+
+ override fun prepare(data: ActionData) {
+ super.prepare(data)
+ if (!visible) {
+ // EditorActivityAction.prepare() hides the action when the editor context is
+ // missing; respect that and skip the plugin providers.
+ return
+ }
+ runCatching {
+ enabled = toolbarAction.isEnabledProvider?.invoke() ?: toolbarAction.isEnabled
+ visible = toolbarAction.isVisibleProvider?.invoke() ?: toolbarAction.isVisible
+ }.onFailure { e ->
+ // A throwing/disposed plugin must never keep a stale icon on the toolbar.
+ Log.w("PluginToolbarActionItem", "prepare failed for '${toolbarAction.id}'", e)
+ enabled = false
+ visible = false
+ }
+ }
+
+ override fun getShowAsActionFlags(data: ActionData): Int = when (toolbarAction.showAsAction) {
+ ShowAsAction.ALWAYS -> MenuItem.SHOW_AS_ACTION_ALWAYS
+ ShowAsAction.IF_ROOM -> MenuItem.SHOW_AS_ACTION_IF_ROOM
+ ShowAsAction.NEVER -> MenuItem.SHOW_AS_ACTION_NEVER
+ ShowAsAction.WITH_TEXT -> MenuItem.SHOW_AS_ACTION_WITH_TEXT
+ ShowAsAction.COLLAPSE_ACTION_VIEW -> MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW
+ }
+
+ override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String =
+ pluginTooltipTag(pluginId, toolbarAction.id)
+
+ override fun retrieveTooltipCategory(): String = pluginCategory(pluginId)
+
+ override suspend fun execAction(data: ActionData): Any {
+ return try {
+ toolbarAction.action.invoke()
+ true
+ } catch (e: Exception) {
+ Log.e("PluginToolbarActionItem", "Error executing toolbar action '${toolbarAction.id}'", e)
+ false
+ }
+ }
+}
diff --git a/app/src/main/java/com/itsaky/androidide/actions/etc/PreviewLayoutAction.kt b/app/src/main/java/com/itsaky/androidide/actions/etc/PreviewLayoutAction.kt
index 3c862a6368..ef102ad0e1 100644
--- a/app/src/main/java/com/itsaky/androidide/actions/etc/PreviewLayoutAction.kt
+++ b/app/src/main/java/com/itsaky/androidide/actions/etc/PreviewLayoutAction.kt
@@ -1,213 +1,167 @@
-/*
- * This file is part of AndroidIDE.
- *
- * AndroidIDE is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * AndroidIDE is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with AndroidIDE. If not, see .
- */
-
-package com.itsaky.androidide.actions.etc
-
-import android.content.Context
-import android.content.Intent
-import android.view.MenuItem
-import androidx.core.content.ContextCompat
-import com.android.aaptcompiler.AaptResourceType.LAYOUT
-import com.android.aaptcompiler.extractPathData
-import com.blankj.utilcode.util.KeyboardUtils
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.itsaky.androidide.actions.ActionData
-import com.itsaky.androidide.actions.EditorRelatedAction
-import com.itsaky.androidide.actions.markInvisible
-import com.itsaky.androidide.activities.editor.EditorHandlerActivity
-import com.itsaky.androidide.compose.preview.ComposePreviewActivity
-import com.itsaky.androidide.idetooltips.TooltipTag
-import com.itsaky.androidide.resources.R
-import org.appdevforall.codeonthego.layouteditor.activities.EditorActivity
-import org.appdevforall.codeonthego.layouteditor.editor.convert.ConvertImportedXml
-import org.appdevforall.codeonthego.layouteditor.utils.Constants
-import com.itsaky.androidide.projects.IProjectManager
-import org.appdevforall.codeonthego.layouteditor.tools.ValidationResult
-import org.appdevforall.codeonthego.layouteditor.tools.XmlLayoutParser
-import org.slf4j.LoggerFactory
-import java.io.File
-
-/** @author Akash Yadav */
-class PreviewLayoutAction(context: Context, override val order: Int) : EditorRelatedAction() {
-
- override val id: String = ID
- override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = when (previewType) {
- PreviewType.COMPOSE -> TooltipTag.EDITOR_TOOLBAR_PREVIEW_COMPOSE
- else -> TooltipTag.EDITOR_TOOLBAR_PREVIEW_LAYOUT
- }
- override var requiresUIThread: Boolean = false
-
- private var previewType: PreviewType = PreviewType.NONE
-
- private enum class PreviewType {
- NONE,
- XML_LAYOUT,
- COMPOSE
- }
-
- companion object {
- const val ID = "ide.editor.previewLayout"
- private val LOG = LoggerFactory.getLogger(PreviewLayoutAction::class.java)
-
- private val COMPOSABLE_PREVIEW_PATTERN = Regex(
- """@Preview\s*(?:\(([^)]*)\))?\s*(?:@\w+(?:\s*\([^)]*\))?[\s\n]*)*(?:(?:private|internal|protected|public|open|override|suspend|inline|external|abstract|final|actual|expect)\s+)*fun\s+(\w+)""",
- setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)
- )
- }
-
- init {
- label = context.getString(R.string.title_preview_layout)
- icon = ContextCompat.getDrawable(context, R.drawable.ic_preview_layout)
- }
-
- override fun prepare(data: ActionData) {
- super.prepare(data)
-
- previewType = PreviewType.NONE
-
- if (data.getActivity() == null) {
- markInvisible()
- return
- }
-
- val viewModel = data.requireActivity().editorViewModel
- val editor = data.getEditor()
- val file = editor?.file
-
- if (file != null && !viewModel.isInitializing) {
- when {
- file.name.endsWith(".xml") -> {
- val type = try {
- extractPathData(file).type
- } catch (err: Throwable) {
- markInvisible()
- return
- }
-
- if (type == LAYOUT) {
- previewType = PreviewType.XML_LAYOUT
- visible = true
- enabled = true
- } else {
- markInvisible()
- }
- }
- file.name.endsWith(".kt") && moduleUsesCompose(file, editor.text.toString()) -> {
- previewType = PreviewType.COMPOSE
- visible = true
- enabled = true
- }
- else -> {
- markInvisible()
- }
- }
- } else {
- if (file != null && file.name.endsWith(".kt") && moduleUsesCompose(file)) {
- previewType = PreviewType.COMPOSE
- visible = true
- enabled = false
- } else {
- markInvisible()
- }
- }
- }
-
- override fun getShowAsActionFlags(data: ActionData): Int {
- val activity = data.getActivity() ?: return super.getShowAsActionFlags(data)
- return if (KeyboardUtils.isSoftInputVisible(activity)) {
- MenuItem.SHOW_AS_ACTION_IF_ROOM
- } else {
- MenuItem.SHOW_AS_ACTION_ALWAYS
- }
- }
-
- override suspend fun execAction(data: ActionData): Boolean {
- val activity = data.requireActivity()
- activity.saveAll()
- return true
- }
-
- override fun postExec(data: ActionData, result: Any) {
- val activity = data.requireActivity()
-
- when (previewType) {
- PreviewType.XML_LAYOUT -> {
- val editor = data.getEditor() ?: return
- val file = editor.file ?: return
- val sourceCode = editor.text.toString()
-
- try {
- val converted = ConvertImportedXml(sourceCode).getXmlConverted(activity)
- if (converted == null) {
- showXmlValidationError(activity, activity.getString(R.string.xml_validation_error_invalid_file))
- return
- }
-
- val validator = XmlLayoutParser(activity)
-
- val result = validator.validateXml(converted, activity)
- when (result) {
- is ValidationResult.Success -> activity.previewXmlLayout(file)
- is ValidationResult.Error -> showXmlValidationError(activity, result.formattedMessage)
- }
- } catch (e: Exception) {
- showXmlValidationError(activity, activity.getString(R.string.xml_error_generic, e.message ?: ""))
- }
- }
- PreviewType.COMPOSE -> {
- val editor = data.getEditor() ?: return
- val file = editor.file ?: return
- activity.showComposePreviewSheet(file, editor.text.toString())
- }
- PreviewType.NONE -> {}
- }
- }
-
- private fun EditorHandlerActivity.previewXmlLayout(file: File) {
- val intent = Intent(this, EditorActivity::class.java)
- intent.putExtra(Constants.EXTRA_KEY_FILE_PATH, file.absolutePath.substringBefore("layout"))
- intent.putExtra(Constants.EXTRA_KEY_LAYOUT_FILE_NAME, file.name.substringBefore("."))
- uiDesignerResultLauncher?.launch(intent)
- }
-
- private fun EditorHandlerActivity.showComposePreviewSheet(file: File, sourceCode: String) {
- ComposePreviewActivity.start(this, sourceCode, file.absolutePath)
- }
-
- private fun showXmlValidationError(activity: Context, message: String?) {
- val safeMessage =
- message?.takeIf { it.isNotBlank() }
- ?: activity.getString(R.string.xml_validation_error_generic)
- (activity as? EditorHandlerActivity)?.runOnUiThread {
- MaterialAlertDialogBuilder(activity)
- .setTitle(R.string.xml_validation_error_title)
- .setMessage(safeMessage)
- .setPositiveButton(android.R.string.ok, null)
- .show()
- }
- }
-
- private fun moduleUsesCompose(file: File): Boolean {
- val module = IProjectManager.getInstance().findModuleForFile(file) ?: return false
- return module.hasExternalDependency("androidx.compose.runtime", "runtime")
- }
-
- private fun moduleUsesCompose(file: File, editorContent: String): Boolean {
- val module = IProjectManager.getInstance().findModuleForFile(file) ?: return false
- return module.hasExternalDependency("androidx.compose.runtime", "runtime") && COMPOSABLE_PREVIEW_PATTERN.findAll(editorContent).any()
- }
-}
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.actions.etc
+
+import android.content.Context
+import android.content.Intent
+import android.view.MenuItem
+import androidx.core.content.ContextCompat
+import com.android.aaptcompiler.AaptResourceType.LAYOUT
+import com.android.aaptcompiler.extractPathData
+import com.blankj.utilcode.util.KeyboardUtils
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.itsaky.androidide.actions.ActionData
+import com.itsaky.androidide.actions.EditorRelatedAction
+import com.itsaky.androidide.actions.markInvisible
+import com.itsaky.androidide.activities.editor.EditorHandlerActivity
+import com.itsaky.androidide.idetooltips.TooltipTag
+import com.itsaky.androidide.resources.R
+import org.appdevforall.codeonthego.layouteditor.activities.EditorActivity
+import org.appdevforall.codeonthego.layouteditor.editor.convert.ConvertImportedXml
+import org.appdevforall.codeonthego.layouteditor.utils.Constants
+import org.appdevforall.codeonthego.layouteditor.tools.ValidationResult
+import org.appdevforall.codeonthego.layouteditor.tools.XmlLayoutParser
+import org.slf4j.LoggerFactory
+import java.io.File
+
+/** @author Akash Yadav */
+class PreviewLayoutAction(context: Context, override val order: Int) : EditorRelatedAction() {
+
+ override val id: String = ID
+ override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String =
+ TooltipTag.EDITOR_TOOLBAR_PREVIEW_LAYOUT
+ override var requiresUIThread: Boolean = false
+
+ private var previewType: PreviewType = PreviewType.NONE
+
+ private enum class PreviewType {
+ NONE,
+ XML_LAYOUT
+ }
+
+ companion object {
+ const val ID = "ide.editor.previewLayout"
+ private val LOG = LoggerFactory.getLogger(PreviewLayoutAction::class.java)
+ }
+
+ init {
+ label = context.getString(R.string.title_preview_layout)
+ icon = ContextCompat.getDrawable(context, R.drawable.ic_preview_layout)
+ }
+
+ override fun prepare(data: ActionData) {
+ super.prepare(data)
+
+ previewType = PreviewType.NONE
+
+ if (data.getActivity() == null) {
+ markInvisible()
+ return
+ }
+
+ val viewModel = data.requireActivity().editorViewModel
+ val editor = data.getEditor()
+ val file = editor?.file
+
+ if (file != null && !viewModel.isInitializing && file.name.endsWith(".xml")) {
+ val type = try {
+ extractPathData(file).type
+ } catch (err: Exception) {
+ LOG.warn("Failed to parse resource path for '{}'; hiding preview action", file.name, err)
+ markInvisible()
+ return
+ }
+
+ if (type == LAYOUT) {
+ previewType = PreviewType.XML_LAYOUT
+ visible = true
+ enabled = true
+ } else {
+ markInvisible()
+ }
+ } else {
+ markInvisible()
+ }
+ }
+
+ override fun getShowAsActionFlags(data: ActionData): Int {
+ val activity = data.getActivity() ?: return super.getShowAsActionFlags(data)
+ return if (KeyboardUtils.isSoftInputVisible(activity)) {
+ MenuItem.SHOW_AS_ACTION_IF_ROOM
+ } else {
+ MenuItem.SHOW_AS_ACTION_ALWAYS
+ }
+ }
+
+ override suspend fun execAction(data: ActionData): Boolean {
+ val activity = data.requireActivity()
+ activity.saveAll()
+ return true
+ }
+
+ override fun postExec(data: ActionData, result: Any) {
+ val activity = data.requireActivity()
+
+ when (previewType) {
+ PreviewType.XML_LAYOUT -> {
+ val editor = data.getEditor() ?: return
+ val file = editor.file ?: return
+ val sourceCode = editor.text.toString()
+
+ try {
+ val converted = ConvertImportedXml(sourceCode).getXmlConverted(activity)
+ if (converted == null) {
+ showXmlValidationError(activity, activity.getString(R.string.xml_validation_error_invalid_file))
+ return
+ }
+
+ val validator = XmlLayoutParser(activity)
+
+ val result = validator.validateXml(converted, activity)
+ when (result) {
+ is ValidationResult.Success -> activity.previewXmlLayout(file)
+ is ValidationResult.Error -> showXmlValidationError(activity, result.formattedMessage)
+ }
+ } catch (e: Exception) {
+ showXmlValidationError(activity, activity.getString(R.string.xml_error_generic, e.message ?: ""))
+ }
+ }
+ PreviewType.NONE -> {}
+ }
+ }
+
+ private fun EditorHandlerActivity.previewXmlLayout(file: File) {
+ val intent = Intent(this, EditorActivity::class.java)
+ intent.putExtra(Constants.EXTRA_KEY_FILE_PATH, file.absolutePath.substringBeforeLast("layout${File.separator}"))
+ intent.putExtra(Constants.EXTRA_KEY_LAYOUT_FILE_NAME, file.name.substringBefore("."))
+ uiDesignerResultLauncher?.launch(intent)
+ }
+
+ private fun showXmlValidationError(activity: Context, message: String?) {
+ val safeMessage =
+ message?.takeIf { it.isNotBlank() }
+ ?: activity.getString(R.string.xml_validation_error_generic)
+ (activity as? EditorHandlerActivity)?.runOnUiThread {
+ MaterialAlertDialogBuilder(activity)
+ .setTitle(R.string.xml_validation_error_title)
+ .setMessage(safeMessage)
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ }
+ }
+}
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 bf3c77705a..c49d747a68 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
@@ -80,6 +80,7 @@ import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory
import com.itsaky.androidide.preferences.internal.EditorPreferences
import com.itsaky.androidide.plugins.manager.ui.PluginDrawableResolver
import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager
+import com.itsaky.androidide.plugins.manager.ui.PluginUiActionManager
import com.itsaky.androidide.projects.ProjectManagerImpl
import com.itsaky.androidide.projects.builder.BuildResult
import com.itsaky.androidide.shortcuts.IdeShortcutActions
@@ -91,6 +92,7 @@ import com.itsaky.androidide.ui.CodeEditorView
import com.itsaky.androidide.fragments.sidebar.EditorSidebarFragment
import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder
import com.itsaky.androidide.utils.DialogUtils.showConfirmationDialog
+import com.itsaky.androidide.utils.EditorActivityActions
import com.itsaky.androidide.utils.EditorSidebarActions
import com.itsaky.androidide.utils.IntentUtils.openImage
import com.itsaky.androidide.utils.UniqueNameBuilder
@@ -485,15 +487,23 @@ open class EditorHandlerActivity :
val data = createToolbarActionData()
content.projectActionsToolbar.clearMenu()
- val actions = getInstance().getActions(EDITOR_TOOLBAR)
- val hiddenIds = PluginBuildActionManager.getInstance().getHiddenActionIds()
- actions.onEachIndexed { index, entry ->
- val action = entry.value
+ // Sort by (order, id) so a plugin's ToolbarAction.order positions its icon among the
+ // built-in actions. The 13 built-ins are registered with contiguous order 0..12, so
+ // this is a visual no-op for them.
+ val actions = getInstance().getActions(EDITOR_TOOLBAR).values
+ .sortedWith(compareBy({ it.order }, { it.id }))
+ val hiddenIds = PluginBuildActionManager.getInstance().getHiddenActionIds() +
+ PluginUiActionManager.getHiddenActionIds()
+ actions.forEachIndexed { index, action ->
val isLast = index == actions.size - 1
action.prepare(data)
- if (action.id in hiddenIds) return@onEachIndexed
+ if (action.id in hiddenIds) return@forEachIndexed
+
+ // Plugin toolbar actions opt into real visibility handling: remove them entirely
+ // when not applicable, instead of the legacy grey-out used by built-in actions.
+ if (action.honorVisibility && !action.visible) return@forEachIndexed
action.icon?.apply {
colorFilter = action.createColorFilter(data)
@@ -1260,6 +1270,8 @@ open class EditorHandlerActivity :
(supportFragmentManager.findFragmentById(R.id.drawer_sidebar) as? EditorSidebarFragment)
?.let { EditorSidebarActions.setup(it) }
+ EditorActivityActions.register(this)
+
invalidateOptionsMenu()
Log.i("EditorHandlerActivity", "Tore down contributions for disabled plugin: $pluginId")
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 0376e3c57c..797c38027a 100644
--- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt
+++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt
@@ -57,6 +57,7 @@ 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.PluginToolbarActionItem
import com.itsaky.androidide.actions.build.PluginBuildActionItem
import com.itsaky.androidide.plugins.extensions.UIExtension
import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager
@@ -186,6 +187,11 @@ class EditorActivityActions {
val action = PluginActionItem(context, menuItem, order++, pluginId)
registry.registerAction(action)
}
+ // Toolbar actions carry their own order so a plugin can position its icon
+ // among the built-in toolbar actions; do not consume the sequential counter.
+ plugin.getToolbarActions().forEach { toolbarAction ->
+ registry.registerAction(PluginToolbarActionItem(context, toolbarAction, pluginId))
+ }
} catch (e: Exception) {
Log.w("plugin_debug", "Failed to register menu items for plugin: ${plugin.javaClass.simpleName}", e)
}
diff --git a/compose-preview/build.gradle.kts b/compose-preview/build.gradle.kts
deleted file mode 100644
index a4d5be3de2..0000000000
--- a/compose-preview/build.gradle.kts
+++ /dev/null
@@ -1,198 +0,0 @@
-import com.itsaky.androidide.build.config.BuildConfig
-import java.util.zip.ZipFile
-
-plugins {
- id("com.android.library")
- id("kotlin-android")
- alias(libs.plugins.kotlin.compose)
-}
-
-val composeVersion = "1.6.0"
-val material3Version = "1.2.0"
-val composeCompilerVersion = "1.5.10"
-
-val composeCompilerJars: Configuration by configurations.creating {
- isTransitive = false
-}
-
-val composeAarsForPreview: Configuration by configurations.creating {
- isTransitive = false
-}
-
-dependencies {
- composeCompilerJars("androidx.compose.compiler:compiler:$composeCompilerVersion")
-
- composeAarsForPreview("androidx.compose.runtime:runtime-android:$composeVersion")
- composeAarsForPreview("androidx.compose.ui:ui-android:$composeVersion")
- composeAarsForPreview("androidx.compose.ui:ui-graphics-android:$composeVersion")
- composeAarsForPreview("androidx.compose.ui:ui-text-android:$composeVersion")
- composeAarsForPreview("androidx.compose.ui:ui-unit-android:$composeVersion")
- composeAarsForPreview("androidx.compose.ui:ui-geometry-android:$composeVersion")
- composeAarsForPreview("androidx.compose.animation:animation-android:$composeVersion")
- composeAarsForPreview("androidx.compose.animation:animation-core-android:$composeVersion")
- composeAarsForPreview("androidx.compose.foundation:foundation-android:$composeVersion")
- composeAarsForPreview("androidx.compose.foundation:foundation-layout-android:$composeVersion")
- composeAarsForPreview("androidx.compose.material3:material3-android:$material3Version")
- composeAarsForPreview("androidx.compose.ui:ui-tooling-preview-android:$composeVersion")
- composeAarsForPreview("androidx.activity:activity-compose:1.8.2")
- composeAarsForPreview("androidx.activity:activity-ktx:1.8.2")
- composeAarsForPreview("androidx.activity:activity:1.8.2")
- composeAarsForPreview("androidx.lifecycle:lifecycle-runtime:2.6.1")
- composeAarsForPreview("androidx.lifecycle:lifecycle-common:2.6.1")
- composeAarsForPreview("androidx.lifecycle:lifecycle-viewmodel:2.6.1")
- composeAarsForPreview("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1")
- composeAarsForPreview("androidx.savedstate:savedstate:1.2.1")
- composeAarsForPreview("androidx.core:core:1.12.0")
- composeAarsForPreview("androidx.core:core-ktx:1.12.0")
- composeAarsForPreview("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3")
- composeAarsForPreview("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
-}
-
-val copyComposeCompilerPlugin by tasks.registering(Copy::class) {
- from(composeCompilerJars)
- into(layout.buildDirectory.dir("compose-jars"))
-
- rename { originalName ->
- when {
- originalName.startsWith("compiler-") -> "compose-compiler-plugin.jar"
- else -> originalName
- }
- }
-}
-
-val extractComposeClasses by tasks.registering {
- dependsOn(copyComposeCompilerPlugin)
-
- val outputDir = layout.buildDirectory.dir("compose-jars")
-
- doLast {
- val outDir = outputDir.get().asFile
- outDir.mkdirs()
-
- composeAarsForPreview.files.forEach { file ->
- when {
- file.name.endsWith(".aar") -> {
- ZipFile(file).use { aar ->
- val classesEntry = aar.getEntry("classes.jar")
- if (classesEntry != null) {
- val targetName = file.nameWithoutExtension + ".jar"
- val targetFile = File(outDir, targetName)
- aar.getInputStream(classesEntry).use { input ->
- targetFile.outputStream().use { output ->
- input.copyTo(output)
- }
- }
- println("Extracted classes.jar from ${file.name} -> $targetName")
- }
- }
- }
- file.name.endsWith(".jar") -> {
- val targetFile = File(outDir, file.name)
- file.copyTo(targetFile, overwrite = true)
- println("Copied JAR: ${file.name}")
- }
- }
- }
- }
-}
-
-fun resolveD8Jar(): File {
- val buildToolsDir = File(android.sdkDirectory, "build-tools")
- return buildToolsDir.listFiles()
- ?.filter { it.isDirectory }
- ?.sortedByDescending { it.name }
- ?.firstNotNullOfOrNull { File(it, "lib/d8.jar").takeIf { jar -> jar.exists() } }
- ?: throw GradleException("D8 jar not found in $buildToolsDir")
-}
-
-fun resolveAndroidJar(): File {
- val platformsDir = File(android.sdkDirectory, "platforms")
- return platformsDir.listFiles()
- ?.filter { it.isDirectory }
- ?.sortedByDescending { it.name }
- ?.firstNotNullOfOrNull { File(it, "android.jar").takeIf { jar -> jar.exists() } }
- ?: throw GradleException("android.jar not found in $platformsDir")
-}
-
-val compileRuntimeDex by tasks.registering {
- dependsOn(extractComposeClasses)
-
- val jarsDir = layout.buildDirectory.dir("compose-jars")
- val dexOutputDir = layout.buildDirectory.dir("compose-jars/dex")
-
- doLast {
- val outDir = dexOutputDir.get().asFile.apply { mkdirs() }
- val runtimeJars = jarsDir.get().asFile.listFiles { file: File ->
- file.extension == "jar" && file.name != "compose-compiler-plugin.jar"
- }?.toList() ?: throw GradleException("No runtime JARs found to compile to DEX")
-
- project.javaexec {
- classpath = files(resolveD8Jar())
- mainClass.set("com.android.tools.r8.D8")
- maxHeapSize = "1g"
- args = buildList {
- add("--release")
- add("--min-api"); add("21")
- add("--lib"); add(resolveAndroidJar().absolutePath)
- add("--output"); add(outDir.absolutePath)
- runtimeJars.forEach { add(it.absolutePath) }
- }
- }
-
- File(outDir, "classes.dex").let {
- if (it.exists()) it.renameTo(File(outDir, "compose-runtime.dex"))
- }
- }
-}
-
-val packageComposeJars by tasks.registering(Zip::class) {
- dependsOn(compileRuntimeDex)
-
- from(layout.buildDirectory.dir("compose-jars"))
- archiveFileName.set("compose-jars.zip")
- destinationDirectory.set(file("src/main/assets/compose"))
-
- doFirst {
- file("src/main/assets/compose").mkdirs()
- }
-}
-
-tasks.named("preBuild") {
- dependsOn(packageComposeJars)
-}
-
-android {
- namespace = "${BuildConfig.PACKAGE_NAME}.compose.preview"
-
- buildFeatures {
- compose = true
- viewBinding = true
- }
-}
-
-dependencies {
- implementation(platform(libs.compose.bom))
- implementation(libs.compose.runtime)
- implementation(libs.compose.ui)
- implementation(libs.compose.ui.tooling.preview)
- implementation(libs.compose.foundation)
- implementation(libs.compose.material3)
- implementation(libs.compose.activity)
- debugImplementation(libs.compose.ui.tooling)
-
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.appcompat)
- implementation(libs.google.material)
- implementation(libs.androidx.constraintlayout)
- implementation(libs.androidx.fragment.ktx)
- implementation(libs.androidx.lifecycle.viewmodel.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
- implementation(libs.common.kotlin.coroutines.android)
-
- implementation(projects.common)
- implementation(projects.editor)
- implementation(projects.editorApi)
- implementation(projects.resources)
- implementation(projects.logger)
- implementation(projects.subprojects.projects)
-}
diff --git a/compose-preview/src/main/AndroidManifest.xml b/compose-preview/src/main/AndroidManifest.xml
deleted file mode 100644
index 2308c43ef5..0000000000
--- a/compose-preview/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewActivity.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewActivity.kt
deleted file mode 100644
index 0423cd17e6..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewActivity.kt
+++ /dev/null
@@ -1,566 +0,0 @@
-package com.itsaky.androidide.compose.preview
-
-import android.content.Context
-import android.content.Intent
-import android.content.res.Configuration
-import android.os.Bundle
-import android.view.View
-import android.widget.AdapterView
-import android.widget.ArrayAdapter
-import android.widget.TextView
-import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.ui.platform.ComposeView
-import androidx.compose.ui.platform.ViewCompositionStrategy
-import androidx.core.view.isVisible
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import com.itsaky.androidide.compose.preview.compiler.CompileDiagnostic
-import com.itsaky.androidide.compose.preview.databinding.ActivityComposePreviewBinding
-import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent
-import com.itsaky.androidide.compose.preview.runtime.ComposeClassLoader
-import com.itsaky.androidide.compose.preview.runtime.ComposableRenderer
-import com.itsaky.androidide.compose.preview.runtime.ProjectResourceContextFactory
-import com.itsaky.androidide.compose.preview.ui.BoundedComposeView
-import com.itsaky.androidide.lookup.Lookup
-import com.itsaky.androidide.projects.builder.BuildService
-import com.itsaky.androidide.resources.R as ResourcesR
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.greenrobot.eventbus.EventBus
-import org.greenrobot.eventbus.Subscribe
-import org.greenrobot.eventbus.ThreadMode
-import org.slf4j.LoggerFactory
-import java.io.File
-import java.util.Locale
-
-class ComposePreviewActivity : AppCompatActivity() {
-
- private lateinit var binding: ActivityComposePreviewBinding
-
- private val viewModel: ComposePreviewViewModel by viewModels()
-
- private var classLoader: ComposeClassLoader? = null
- private var singleRenderer: ComposableRenderer? = null
- private val multiRenderers = mutableMapOf()
-
- private var loadedClass: Class<*>? = null
- private var loadJob: Job? = null
-
- private val resourceContextFactory by lazy { ProjectResourceContextFactory(this) }
- private var previewInstances: List = emptyList()
- private var renderedKeys: List = emptyList()
-
- private var toggleMenuItem: android.view.MenuItem? = null
- private var selectorAdapter: ArrayAdapter? = null
- private var selectedSingleKey: String? = null
- private var suppressSelectionCallback = false
-
- private val sourceCode: String by lazy {
- intent.getStringExtra(EXTRA_SOURCE_CODE) ?: ""
- }
-
- private val filePath: String by lazy {
- intent.getStringExtra(EXTRA_FILE_PATH) ?: ""
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- binding = ActivityComposePreviewBinding.inflate(layoutInflater)
- setContentView(binding.root)
-
- setupClassLoader()
- setupToolbar()
- setupPreviewSelector()
- setupSinglePreview()
- setupBuildButton()
- observeState()
-
- viewModel.initialize(this, filePath, sourceCode)
-
- EventBus.getDefault().register(this)
- }
-
- @Subscribe(threadMode = ThreadMode.MAIN)
- fun onDocumentChanged(event: DocumentChangeEvent) {
- if (filePath.isBlank()) return
- if (event.changedFile.toFile().absolutePath != File(filePath).absolutePath) return
- val newText = event.newText ?: return
- viewModel.onSourceChanged(newText)
- }
-
- private fun setupClassLoader() {
- classLoader = ComposeClassLoader(this)
- }
-
- private fun setupToolbar() {
- binding.toolbar.title = filePath.substringAfterLast('/').ifEmpty {
- getString(ResourcesR.string.title_compose_preview)
- }
- binding.toolbar.setNavigationOnClickListener { finish() }
-
- toggleMenuItem = binding.toolbar.menu.findItem(R.id.action_toggle_mode)
- binding.toolbar.setOnMenuItemClickListener { menuItem ->
- when (menuItem.itemId) {
- R.id.action_toggle_mode -> {
- viewModel.toggleDisplayMode()
- true
- }
- else -> false
- }
- }
- }
-
- private fun setupPreviewSelector() {
- selectorAdapter = ArrayAdapter(
- this,
- android.R.layout.simple_spinner_item,
- mutableListOf()
- )
- selectorAdapter?.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
- binding.previewSelector.adapter = selectorAdapter
-
- binding.previewSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
- override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
- if (suppressSelectionCallback) return
- val instance = previewInstances.getOrNull(position) ?: return
- selectedSingleKey = instance.cardKey
- if (viewModel.displayMode.value == DisplayMode.SINGLE) {
- renderSinglePreview()
- }
- }
- override fun onNothingSelected(parent: AdapterView<*>?) {}
- }
- }
-
- private fun setupSinglePreview() {
- binding.singlePreviewView.setViewCompositionStrategy(
- ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
- )
- singleRenderer = ComposableRenderer(binding.singlePreviewView)
- }
-
- private fun setupBuildButton() {
- binding.buildProjectButton.setOnClickListener {
- triggerBuild()
- }
- binding.errorBuildButton.setOnClickListener {
- triggerBuildFromError()
- }
- }
-
- private fun triggerBuild() {
- val state = viewModel.previewState.value
- if (state !is PreviewState.NeedsBuild) return
-
- executeBuild(state.modulePath, state.variantName)
- }
-
- private fun triggerBuildFromError() {
- val modulePath = viewModel.getModulePath()
- val variantName = viewModel.getVariantName()
- executeBuild(modulePath, variantName)
- }
-
- private fun executeBuild(modulePath: String, variantName: String) {
- val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)
- if (buildService == null) {
- LOG.error("BuildService not available")
- return
- }
-
- if (buildService.isBuildInProgress) {
- LOG.warn("Build already in progress")
- return
- }
-
- viewModel.setBuildingState()
-
- val capitalizedVariant = variantName.replaceFirstChar { it.uppercaseChar() }
- val task = if (modulePath.isNotEmpty()) {
- "$modulePath:assemble$capitalizedVariant"
- } else {
- "assemble$capitalizedVariant"
- }
- LOG.info("Running build task: {}", task)
-
- buildService.executeTasks(task).whenComplete { result, error ->
- runOnUiThread {
- if (error != null || !result.isSuccessful) {
- LOG.error("Build failed", error)
- viewModel.setBuildFailed()
- } else {
- LOG.info("Build completed, refreshing preview")
- viewModel.refreshAfterBuild(this@ComposePreviewActivity)
- }
- }
- }
- }
-
- private fun observeState() {
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.previewState.collect { state ->
- handlePreviewState(state)
- }
- }
- }
-
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.displayMode.collect { mode ->
- updateDisplayMode(mode)
- }
- }
- }
- }
-
- private fun handlePreviewState(state: PreviewState) {
- binding.loadingOverlay.isVisible = state is PreviewState.Initializing ||
- state is PreviewState.Compiling ||
- state is PreviewState.Idle ||
- state is PreviewState.Building
- binding.errorContainer.isVisible = state is PreviewState.Error
- binding.emptyContainer.isVisible = state is PreviewState.Empty
- binding.needsBuildContainer.isVisible = state is PreviewState.NeedsBuild
-
- val isReady = state is PreviewState.Ready
- val isAllMode = viewModel.displayMode.value == DisplayMode.ALL
-
- binding.previewScrollView.isVisible = isReady && isAllMode
- binding.singlePreviewView.isVisible = isReady && !isAllMode
-
- when (state) {
- is PreviewState.Idle -> {
- binding.statusText.text = "Rendering..."
- binding.statusSubtext.isVisible = false
- binding.loadingIndicator.isVisible = true
- }
- is PreviewState.Initializing -> {
- binding.statusText.text = "Initializing..."
- binding.statusSubtext.isVisible = false
- binding.loadingIndicator.isVisible = true
- }
- is PreviewState.Compiling -> {
- binding.statusText.text = "Compiling..."
- binding.statusSubtext.isVisible = false
- binding.loadingIndicator.isVisible = true
- }
- is PreviewState.Building -> {
- binding.statusText.text = "Building project..."
- binding.statusSubtext.text = "First build may take 10-15 minutes"
- binding.statusSubtext.isVisible = true
- binding.loadingIndicator.isVisible = true
- }
- is PreviewState.NeedsBuild -> {
- LOG.debug("Build required for multi-file preview support")
- }
- is PreviewState.Empty -> {
- LOG.debug("No preview composables found")
- }
- is PreviewState.Ready -> {
- loadAndRender(state)
- }
- is PreviewState.Error -> {
- binding.errorMessage.text = state.message
- val details = if (state.diagnostics.isNotEmpty()) {
- state.diagnostics.joinToString("\n\n") { diagnostic ->
- buildString {
- if (diagnostic.file != null || diagnostic.line != null) {
- diagnostic.file?.let { append(it.substringAfterLast('/')) }
- diagnostic.line?.let { append(":$it") }
- diagnostic.column?.let { append(":$it") }
- append("\n")
- }
- append("[${diagnostic.severity}] ${diagnostic.message}")
- }
- }
- } else {
- state.message
- }
- binding.errorDetails.text = details
- binding.errorDetails.isVisible = true
- binding.errorBuildButton.isVisible = viewModel.canTriggerBuild()
-
- LOG.error("Preview error: {}", state.message)
- LOG.error("Diagnostics: {}", details)
- }
- }
- }
-
- private fun updateDisplayMode(mode: DisplayMode) {
- val isAllMode = mode == DisplayMode.ALL
-
- toggleMenuItem?.setIcon(
- if (isAllMode) R.drawable.ic_view_single else R.drawable.ic_view_grid
- )
-
- refreshSelector()
-
- val state = viewModel.previewState.value
- if (state is PreviewState.Ready) {
- binding.previewScrollView.isVisible = isAllMode
- binding.singlePreviewView.isVisible = !isAllMode
-
- if (isAllMode) {
- renderAllPreviews()
- } else {
- renderSinglePreview()
- }
- }
- }
-
- private fun refreshSelector() {
- val labels = previewInstances.map { it.label }
-
- suppressSelectionCallback = true
- selectorAdapter?.clear()
- selectorAdapter?.addAll(labels)
- selectorAdapter?.notifyDataSetChanged()
- val currentIndex = previewInstances.indexOfFirst { it.cardKey == selectedSingleKey }
- if (currentIndex >= 0) {
- binding.previewSelector.setSelection(currentIndex)
- }
- suppressSelectionCallback = false
-
- binding.previewSelector.isVisible =
- viewModel.displayMode.value == DisplayMode.SINGLE && labels.size > 1
- }
-
- private fun loadAndRender(state: PreviewState.Ready) {
- val loader = classLoader ?: return
- LOG.info("Runtime DEX from state: {}, project DEX files: {}",
- state.runtimeDex?.absolutePath ?: "null", state.projectDexFiles.size)
- loadedClass = null
- loadJob?.cancel()
- loadJob = lifecycleScope.launch {
- val result = withContext(Dispatchers.IO) {
- loader.setProjectDexFiles(state.projectDexFiles)
- loader.setRuntimeDex(state.runtimeDex)
- val clazz = loader.loadClass(state.dexFile, state.className)
- val instances = if (clazz == null) emptyList() else buildPreviewInstances(state)
- clazz to instances
- }
- val clazz = result.first
- if (clazz == null) {
- LOG.error("render: failed to load class {}", state.className)
- return@launch
- }
- loadedClass = clazz
- previewInstances = result.second
- if (selectedSingleKey == null || previewInstances.none { it.cardKey == selectedSingleKey }) {
- selectedSingleKey = previewInstances.firstOrNull()?.cardKey
- }
- refreshSelector()
- if (viewModel.displayMode.value == DisplayMode.ALL) {
- renderAllPreviews()
- } else {
- renderSinglePreview()
- }
- }
- }
-
- private fun buildPreviewInstances(state: PreviewState.Ready): List {
- return state.previewConfigs.flatMap { config -> instancesForConfig(config, state) }
- }
-
- private fun instancesForConfig(config: PreviewConfig, state: PreviewState.Ready): List {
- val context = resourceContextFactory.contextFor(state.resourceApk, buildConfiguration(config))
- val single = listOf(PreviewInstance(config, context, null, 0, 1))
-
- val provider = config.parameterProvider ?: return single
-
- val values = resolveParameterValues(state.dexFile, provider, config.parameterLimit)
- if (values.isEmpty()) return single
-
- return values.mapIndexed { index, value ->
- PreviewInstance(config, context, value, index, values.size)
- }
- }
-
- private fun buildConfiguration(config: PreviewConfig): Configuration {
- val configuration = Configuration(resources.configuration)
- config.uiMode?.let { uiMode ->
- val typeBits = uiMode and Configuration.UI_MODE_TYPE_MASK
- val nightBits = uiMode and Configuration.UI_MODE_NIGHT_MASK
- var merged = configuration.uiMode
- if (typeBits != 0) {
- merged = (merged and Configuration.UI_MODE_TYPE_MASK.inv()) or typeBits
- }
- if (nightBits != 0) {
- merged = (merged and Configuration.UI_MODE_NIGHT_MASK.inv()) or nightBits
- }
- configuration.uiMode = merged
- }
- config.fontScale?.let { configuration.fontScale = it }
- config.locale?.let { configuration.setLocale(Locale.forLanguageTag(it.replace('_', '-'))) }
- return configuration
- }
-
- private fun resolveParameterValues(dexFile: File, providerFqn: String, limit: Int): List {
- val loader = classLoader ?: return emptyList()
- return try {
- val providerClass = loader.loadClass(dexFile, providerFqn) ?: run {
- LOG.warn("@PreviewParameter provider not found: {}", providerFqn)
- return emptyList()
- }
- val instance = providerClass.getDeclaredConstructor().newInstance()
- val values = providerClass.getMethod("getValues").invoke(instance) as? Sequence<*>
- ?: return emptyList()
- val capped = values.take(minOf(limit, MAX_PARAMETER_VALUES)).toList()
- capped
- } catch (e: Throwable) {
- LOG.error("Failed to resolve @PreviewParameter values from {}", providerFqn, e)
- emptyList()
- }
- }
-
- private fun renderAllPreviews() {
- val container = binding.previewListContainer
- val clazz = loadedClass ?: return
- val instances = previewInstances
- val keys = instances.map { it.cardKey }
-
- LOG.debug("renderAllPreviews called with {} previews: {}", keys.size, keys)
-
- if (keys == renderedKeys && multiRenderers.keys == keys.toSet()) {
- LOG.debug("Same functions, re-rendering existing views")
- instances.forEach { instance ->
- multiRenderers[instance.cardKey]?.render(
- clazz, instance.config.functionName, instance.context, instance.parameterValue, instance.config.parameterIndex
- )
- }
- return
- }
-
- LOG.debug("Creating new preview items")
- container.removeAllViews()
- multiRenderers.clear()
- renderedKeys = keys
-
- instances.forEachIndexed { index, instance ->
- LOG.debug("Adding preview item {}: {}", index, instance.config.functionName)
- val previewItem = createPreviewItem(instance, index == 0)
- container.addView(previewItem)
-
- val boundedView = previewItem.findViewById(R.id.composePreview)
- applyCardAttributes(boundedView.composeView, boundedView, instance.config)
-
- boundedView.setViewCompositionStrategy(
- ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
- )
-
- val renderer = ComposableRenderer(boundedView.composeView)
- multiRenderers[instance.cardKey] = renderer
-
- renderer.render(clazz, instance.config.functionName, instance.context, instance.parameterValue, instance.config.parameterIndex)
- }
-
- LOG.debug("Container now has {} children", container.childCount)
- }
-
- private fun renderSinglePreview() {
- val clazz = loadedClass ?: return
- val instance = previewInstances.firstOrNull { it.cardKey == selectedSingleKey }
- ?: previewInstances.firstOrNull()
- ?: return
- selectedSingleKey = instance.cardKey
- applyBackground(binding.singlePreviewView, instance.config)
- singleRenderer?.render(clazz, instance.config.functionName, instance.context, instance.parameterValue, instance.config.parameterIndex)
- }
-
- private fun applyCardAttributes(composeView: View, boundedView: BoundedComposeView, config: PreviewConfig) {
- val density = resources.displayMetrics.density
- boundedView.explicitWidthPx = config.widthDp?.let { (it * density).toInt() }
- boundedView.explicitHeightPx = config.heightDp?.let { (it * density).toInt() }
- applyBackground(composeView, config)
- }
-
- private fun applyBackground(view: View, config: PreviewConfig) {
- view.setBackgroundColor(
- if (config.showBackground) {
- resolveBackgroundColor(config.backgroundColor)
- } else {
- android.graphics.Color.TRANSPARENT
- }
- )
- }
-
- private fun resolveBackgroundColor(raw: Long?): Int {
- if (raw == null || raw == 0L) return DEFAULT_PREVIEW_BACKGROUND
- val argb = raw.toInt()
- return if ((argb ushr 24) == 0) argb or OPAQUE_ALPHA else argb
- }
-
- private fun createPreviewItem(instance: PreviewInstance, isFirst: Boolean): View {
- val item = layoutInflater.inflate(R.layout.item_preview_card, binding.previewListContainer, false)
-
- item.findViewById(R.id.previewLabel)?.let { label ->
- label.text = buildString {
- append(instance.label)
- instance.config.group?.let { append(" ยท ").append(it) }
- }
- }
-
- item.findViewById(R.id.divider)?.let { divider ->
- divider.isVisible = !isFirst
- }
-
- return item
- }
-
- private data class PreviewInstance(
- val config: PreviewConfig,
- val context: Context,
- val parameterValue: Any?,
- val valueIndex: Int,
- val valueCount: Int
- ) {
- val cardKey: String get() = if (valueCount > 1) "${config.key}[$valueIndex]" else config.key
- val label: String get() = if (valueCount > 1) "${config.displayName} [$valueIndex]" else config.displayName
- }
-
- override fun onDestroy() {
- super.onDestroy()
- EventBus.getDefault().unregister(this)
- loadJob?.cancel()
- loadJob = null
- loadedClass = null
- previewInstances = emptyList()
- renderedKeys = emptyList()
- resourceContextFactory.release()
- multiRenderers.clear()
- singleRenderer = null
- classLoader?.release()
- classLoader = null
- selectorAdapter = null
- toggleMenuItem = null
- }
-
- override fun onLowMemory() {
- super.onLowMemory()
- classLoader?.release()
- LOG.warn("Low memory - released preview resources")
- }
-
- companion object {
- private val LOG = LoggerFactory.getLogger(ComposePreviewActivity::class.java)
- private val DEFAULT_PREVIEW_BACKGROUND = android.graphics.Color.WHITE
- private const val OPAQUE_ALPHA = 0xFF shl 24
- private const val MAX_PARAMETER_VALUES = 25
-
- private const val EXTRA_SOURCE_CODE = "source_code"
- private const val EXTRA_FILE_PATH = "file_path"
-
- fun start(context: Context, sourceCode: String, filePath: String) {
- val intent = Intent(context, ComposePreviewActivity::class.java).apply {
- putExtra(EXTRA_SOURCE_CODE, sourceCode)
- putExtra(EXTRA_FILE_PATH, filePath)
- }
- context.startActivity(intent)
- }
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewFragment.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewFragment.kt
deleted file mode 100644
index 76fe6c6226..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewFragment.kt
+++ /dev/null
@@ -1,199 +0,0 @@
-package com.itsaky.androidide.compose.preview
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.compose.ui.platform.ComposeView
-import androidx.core.view.isVisible
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.viewModels
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import com.itsaky.androidide.compose.preview.databinding.FragmentComposePreviewBinding
-import com.itsaky.androidide.compose.preview.runtime.ComposeClassLoader
-import com.itsaky.androidide.compose.preview.runtime.ComposableRenderer
-import com.itsaky.androidide.resources.R as ResourcesR
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.slf4j.LoggerFactory
-
-class ComposePreviewFragment : Fragment() {
-
- private var _binding: FragmentComposePreviewBinding? = null
- private val binding get() = _binding ?: throw IllegalStateException("Binding accessed after view destroyed")
-
- private val viewModel: ComposePreviewViewModel by viewModels()
-
- private var classLoader: ComposeClassLoader? = null
- private var renderer: ComposableRenderer? = null
-
- private var sourceCode: String = DEFAULT_SOURCE
- private var onNavigateBack: (() -> Unit)? = null
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
- _binding = FragmentComposePreviewBinding.inflate(inflater, container, false)
- return binding.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
-
- setupToolbar()
- setupPreview()
- observeState()
-
- val filePath = arguments?.getString(ARG_FILE_PATH) ?: ""
- arguments?.getString(ARG_SOURCE_CODE)?.let {
- sourceCode = it
- }
- viewModel.initialize(requireContext(), filePath, sourceCode)
- }
-
- private fun setupToolbar() {
- binding.toolbar.setNavigationOnClickListener {
- onNavigateBack?.invoke() ?: parentFragmentManager.popBackStack()
- }
- }
-
- private fun setupPreview() {
- classLoader = ComposeClassLoader(requireContext())
- renderer = ComposableRenderer(binding.composePreview)
- }
-
- private fun observeState() {
- viewLifecycleOwner.lifecycleScope.launch {
- viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.previewState.collect { state ->
- handleState(state)
- }
- }
- }
- }
-
- private fun handleState(state: PreviewState) {
- binding.loadingIndicator.isVisible = state is PreviewState.Compiling || state is PreviewState.Building
- binding.initializingText.isVisible = state is PreviewState.Initializing ||
- state is PreviewState.Empty ||
- state is PreviewState.NeedsBuild ||
- state is PreviewState.Building
- binding.errorOverlay.isVisible = state is PreviewState.Error
- binding.composePreview.isVisible = state is PreviewState.Ready
-
- when (state) {
- is PreviewState.Idle -> {
- if (sourceCode.isNotBlank()) {
- viewModel.compileNow(sourceCode)
- }
- }
- is PreviewState.Initializing -> {
- binding.initializingText.setText(ResourcesR.string.preview_initializing)
- }
- is PreviewState.Empty -> {
- binding.initializingText.setText(ResourcesR.string.preview_empty_title)
- }
- is PreviewState.Compiling -> {
- LOG.debug("Compiling...")
- }
- is PreviewState.Building -> {
- binding.initializingText.setText(ResourcesR.string.preview_building_project)
- binding.loadingIndicator.isVisible = true
- }
- is PreviewState.NeedsBuild -> {
- binding.initializingText.setText(ResourcesR.string.preview_build_required_title)
- }
- is PreviewState.Ready -> {
- val loader = classLoader ?: return
- val render = renderer ?: return
- val config = state.previewConfigs.firstOrNull() ?: return
- viewLifecycleOwner.lifecycleScope.launch {
- val clazz = withContext(Dispatchers.IO) {
- loader.setProjectDexFiles(state.projectDexFiles)
- loader.setRuntimeDex(state.runtimeDex)
- loader.loadClass(state.dexFile, state.className)
- } ?: return@launch
- render.render(clazz, config.functionName, null, null)
- }
- }
- is PreviewState.Error -> {
- showError(state)
- }
- }
- }
-
- private fun showError(state: PreviewState.Error) {
- binding.errorOverlay.isVisible = true
- binding.errorMessage.text = state.message
-
- val details = state.diagnostics.joinToString("\n") { diagnostic ->
- buildString {
- diagnostic.file?.let { append("$it:") }
- diagnostic.line?.let { append("$it:") }
- diagnostic.column?.let { append("$it ") }
- append("[${diagnostic.severity}] ")
- append(diagnostic.message)
- }
- }
- binding.errorDetails.text = details
- binding.errorDetails.isVisible = details.isNotBlank()
- }
-
- fun updateSource(source: String) {
- sourceCode = source
- viewModel.onSourceChanged(source)
- }
-
- fun setNavigateBackListener(listener: () -> Unit) {
- onNavigateBack = listener
- }
-
- override fun onDestroyView() {
- super.onDestroyView()
- classLoader?.release()
- classLoader = null
- renderer = null
- _binding = null
- }
-
- override fun onLowMemory() {
- super.onLowMemory()
- classLoader?.release()
- classLoader = null
- renderer = null
- LOG.warn("Low memory - released preview resources")
- }
-
- companion object {
- private val LOG = LoggerFactory.getLogger(ComposePreviewFragment::class.java)
-
- private const val ARG_SOURCE_CODE = "source_code"
- private const val ARG_FILE_PATH = "file_path"
-
- private const val DEFAULT_SOURCE = """
-package preview
-
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-
-@Composable
-fun Preview() {
- Text("Hello, Compose Preview!")
-}
-"""
-
- fun newInstance(sourceCode: String? = null, filePath: String? = null): ComposePreviewFragment {
- return ComposePreviewFragment().apply {
- arguments = Bundle().apply {
- sourceCode?.let { putString(ARG_SOURCE_CODE, it) }
- filePath?.let { putString(ARG_FILE_PATH, it) }
- }
- }
- }
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewViewModel.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewViewModel.kt
deleted file mode 100644
index 1e604d3d88..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewViewModel.kt
+++ /dev/null
@@ -1,340 +0,0 @@
-package com.itsaky.androidide.compose.preview
-
-import android.content.Context
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.itsaky.androidide.compose.preview.compiler.CompileDiagnostic
-import com.itsaky.androidide.compose.preview.data.repository.CompilationException
-import com.itsaky.androidide.compose.preview.data.repository.ComposePreviewRepository
-import com.itsaky.androidide.compose.preview.data.repository.ComposePreviewRepositoryImpl
-import com.itsaky.androidide.compose.preview.data.repository.InitializationResult
-import com.itsaky.androidide.compose.preview.domain.PreviewSourceParser
-import com.itsaky.androidide.compose.preview.domain.model.ParsedPreviewSource
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.debounce
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import org.slf4j.LoggerFactory
-import java.io.File
-import java.util.concurrent.atomic.AtomicBoolean
-
-sealed class PreviewState {
- data object Idle : PreviewState()
- data object Initializing : PreviewState()
- data object Compiling : PreviewState()
- data object Empty : PreviewState()
- data object Building : PreviewState()
- data class Ready(
- val dexFile: File,
- val className: String,
- val previewConfigs: List,
- val runtimeDex: File?,
- val projectDexFiles: List = emptyList(),
- val resourceApk: File? = null
- ) : PreviewState()
- data class Error(
- val message: String,
- val diagnostics: List = emptyList()
- ) : PreviewState()
- data class NeedsBuild(val modulePath: String, val variantName: String = "debug") : PreviewState()
-}
-
-enum class DisplayMode { ALL, SINGLE }
-
-data class PreviewConfig(
- val functionName: String,
- val key: String,
- val displayName: String,
- val group: String? = null,
- val widthDp: Int? = null,
- val heightDp: Int? = null,
- val showBackground: Boolean = false,
- val backgroundColor: Long? = null,
- val uiMode: Int? = null,
- val fontScale: Float? = null,
- val locale: String? = null,
- val parameterProvider: String? = null,
- val parameterLimit: Int = Int.MAX_VALUE,
- val parameterIndex: Int = 0
-)
-
-@OptIn(FlowPreview::class)
-class ComposePreviewViewModel(
- private val repository: ComposePreviewRepository = ComposePreviewRepositoryImpl(),
- private val sourceParser: PreviewSourceParser = PreviewSourceParser()
-) : ViewModel() {
-
- private val _previewState = MutableStateFlow(PreviewState.Idle)
- val previewState: StateFlow = _previewState.asStateFlow()
-
- private val _displayMode = MutableStateFlow(DisplayMode.ALL)
- val displayMode: StateFlow = _displayMode.asStateFlow()
-
- private val _selectedPreview = MutableStateFlow(null)
- val selectedPreview: StateFlow = _selectedPreview.asStateFlow()
-
- private val _availablePreviews = MutableStateFlow>(emptyList())
- val availablePreviews: StateFlow> = _availablePreviews.asStateFlow()
-
- private val sourceChanges = MutableSharedFlow()
-
- private var currentSource: String = ""
- private var cachedFilePath: String = ""
- private var modulePath: String? = null
- private var variantName: String = "debug"
- private var resourceApk: File? = null
- private val isInitialized = AtomicBoolean(false)
- private var initializationDeferred = kotlinx.coroutines.CompletableDeferred()
- private val initMutex = Mutex()
-
- init {
- viewModelScope.launch {
- sourceChanges
- .debounce(DEBOUNCE_MS)
- .distinctUntilChanged()
- .collect { source ->
- val parsed = withContext(Dispatchers.Default) { parseAndValidateSource(source) }
- if (parsed != null) {
- compilePreview(source, parsed)
- }
- }
- }
- }
-
- fun initialize(context: Context, filePath: String, source: String) {
- if (!isInitialized.compareAndSet(false, true)) return
-
- cachedFilePath = filePath
- currentSource = source
-
- viewModelScope.launch {
- _previewState.value = PreviewState.Initializing
-
- repository.initialize(context, filePath)
- .onSuccess { result ->
- when (result) {
- is InitializationResult.Ready -> {
- modulePath = result.projectContext.modulePath
- variantName = result.projectContext.variantName
- resourceApk = result.projectContext.resourceApk
- initializationDeferred.complete(Unit)
- LOG.info("ViewModel initialized, modulePath={}, variant={}",
- modulePath, variantName)
- if (currentSource.isNotBlank()) {
- compileNow(currentSource)
- } else {
- _previewState.value = PreviewState.Idle
- }
- }
- is InitializationResult.NeedsBuild -> {
- modulePath = result.modulePath
- variantName = result.variantName
- initializationDeferred.complete(Unit)
- _previewState.value = PreviewState.NeedsBuild(
- result.modulePath,
- result.variantName
- )
- }
- is InitializationResult.Failed -> {
- isInitialized.set(false)
- initializationDeferred.complete(Unit)
- _previewState.value = PreviewState.Error(result.message)
- }
- }
- }
- .onFailure { error ->
- LOG.error("Initialization failed", error)
- isInitialized.set(false)
- initializationDeferred.complete(Unit)
- _previewState.value = PreviewState.Error(
- error.message ?: "Initialization failed"
- )
- }
- }
- }
-
- fun onSourceChanged(source: String) {
- currentSource = source
- viewModelScope.launch {
- sourceChanges.emit(source)
- }
- }
-
- fun compileNow(source: String) {
- currentSource = source
- viewModelScope.launch {
- val parsed = withContext(Dispatchers.Default) { parseAndValidateSource(source) } ?: return@launch
- compilePreview(source, parsed)
- }
- }
-
- private fun parseAndValidateSource(source: String): ParsedPreviewSource? {
- if (_previewState.value is PreviewState.NeedsBuild) {
- LOG.debug("Skipping source processing - build required")
- return null
- }
-
- val parsed = sourceParser.parse(source)
- if (parsed == null) {
- LOG.warn("parse: rejected - missing package declaration (sourceLen={})", source.length)
- _previewState.value = PreviewState.Error("Missing package declaration in source")
- return null
- }
-
- if (parsed.previewConfigs.isEmpty()) {
- LOG.warn("parse: no @Preview functions found in package {}", parsed.packageName)
- _previewState.value = PreviewState.Empty
- return null
- }
-
- updateAvailablePreviews(parsed.previewConfigs)
- return parsed
- }
-
- private fun updateAvailablePreviews(configs: List) {
- val names = configs.map { it.displayName }
- _availablePreviews.value = names
- if (_selectedPreview.value == null || !names.contains(_selectedPreview.value)) {
- _selectedPreview.value = names.firstOrNull()
- }
- }
-
- private suspend fun compilePreview(source: String, parsed: ParsedPreviewSource) {
- initializationDeferred.await()
-
- if (!isInitialized.get()) {
- LOG.debug("Skipping compilePreview - initialization failed")
- return
- }
-
- if (_previewState.value is PreviewState.NeedsBuild) {
- LOG.debug("Skipping compilePreview - build required")
- return
- }
-
- _previewState.value = PreviewState.Compiling
-
- repository.compilePreview(source, parsed)
- .onSuccess { result ->
- _previewState.value = PreviewState.Ready(
- dexFile = result.dexFile,
- className = result.className,
- previewConfigs = parsed.previewConfigs,
- runtimeDex = result.runtimeDex,
- projectDexFiles = result.projectDexFiles,
- resourceApk = resourceApk
- )
- }
- .onFailure { error ->
- val diagnostics = if (error is CompilationException) error.diagnostics else emptyList()
- LOG.error("compile: FAILED - {} ({} diagnostic(s))", error.message, diagnostics.size)
- _previewState.value = PreviewState.Error(
- message = error.message ?: "Compilation failed",
- diagnostics = diagnostics
- )
- }
- }
-
- fun setDisplayMode(mode: DisplayMode) {
- _displayMode.value = mode
- }
-
- fun toggleDisplayMode() {
- _displayMode.value = when (_displayMode.value) {
- DisplayMode.ALL -> DisplayMode.SINGLE
- DisplayMode.SINGLE -> DisplayMode.ALL
- }
- }
-
- fun selectPreview(functionName: String) {
- if (_availablePreviews.value.contains(functionName)) {
- _selectedPreview.value = functionName
- }
- }
-
- fun getModulePath(): String = modulePath ?: ""
- fun getVariantName(): String = variantName
- fun canTriggerBuild(): Boolean = !modulePath.isNullOrEmpty()
-
- fun setBuildingState() {
- _previewState.value = PreviewState.Building
- }
-
- fun setBuildFailed() {
- _previewState.value = PreviewState.Error("Build failed. Check build output for details.")
- }
-
- fun refreshAfterBuild(context: Context) {
- viewModelScope.launch {
- initMutex.withLock {
- LOG.debug("refreshAfterBuild: starting, currentSource length={}", currentSource.length)
-
- repository.reset()
- isInitialized.set(false)
- initializationDeferred = kotlinx.coroutines.CompletableDeferred()
-
- _previewState.value = PreviewState.Initializing
-
- repository.initialize(context, cachedFilePath)
- .onSuccess { result ->
- when (result) {
- is InitializationResult.Ready -> {
- modulePath = result.projectContext.modulePath
- variantName = result.projectContext.variantName
- resourceApk = result.projectContext.resourceApk
- isInitialized.set(true)
- initializationDeferred.complete(Unit)
- LOG.debug("refreshAfterBuild: initialization complete, state=Ready")
- if (currentSource.isNotBlank()) {
- compileNow(currentSource)
- } else {
- _previewState.value = PreviewState.Idle
- }
- }
- is InitializationResult.NeedsBuild -> {
- modulePath = result.modulePath
- variantName = result.variantName
- isInitialized.set(true)
- initializationDeferred.complete(Unit)
- _previewState.value = PreviewState.NeedsBuild(
- result.modulePath,
- result.variantName
- )
- }
- is InitializationResult.Failed -> {
- initializationDeferred.complete(Unit)
- LOG.error("refreshAfterBuild: initialization failed - {}", result.message)
- _previewState.value = PreviewState.Error(result.message)
- }
- }
- }
- .onFailure { error ->
- initializationDeferred.complete(Unit)
- LOG.error("refreshAfterBuild: initialization failed", error)
- _previewState.value = PreviewState.Error(
- error.message ?: "Initialization failed"
- )
- }
- }
- }
- }
-
- override fun onCleared() {
- super.onCleared()
- repository.reset()
- LOG.debug("ComposePreviewViewModel cleared")
- }
-
- companion object {
- private val LOG = LoggerFactory.getLogger(ComposePreviewViewModel::class.java)
- private const val DEBOUNCE_MS = 500L
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/CompilerDaemon.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/CompilerDaemon.kt
deleted file mode 100644
index df71e24c82..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/CompilerDaemon.kt
+++ /dev/null
@@ -1,448 +0,0 @@
-package com.itsaky.androidide.compose.preview.compiler
-
-import com.itsaky.androidide.utils.Environment
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import kotlinx.coroutines.withTimeoutOrNull
-import org.slf4j.LoggerFactory
-import java.io.BufferedReader
-import java.io.File
-import java.io.InputStreamReader
-import java.io.OutputStreamWriter
-import java.util.concurrent.TimeUnit
-
-class CompilerDaemon(
- private val classpathManager: ComposeClasspathManager,
- private val workDir: File
-) {
- private var daemonProcess: Process? = null
- private var processWriter: OutputStreamWriter? = null
- private var processReader: BufferedReader? = null
- private var errorReader: BufferedReader? = null
- private val mutex = Mutex()
-
- private var idleTimeoutJob: Job? = null
- private val timeoutScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
- private var isStartingUp = false
-
- private val wrapperDir = File(workDir, "daemon").apply { mkdirs() }
- private val wrapperClass = File(wrapperDir, "CompilerWrapper.class")
-
- suspend fun compile(
- sourceFiles: List,
- outputDir: File,
- classpath: String,
- composePlugin: File
- ): CompilerResult = mutex.withLock {
- withContext(Dispatchers.IO) {
- ensureDaemonRunning()
-
- val args = buildCompilerArgs(sourceFiles, outputDir, classpath, composePlugin)
- val argsLine = "COMPILE\u0000" + args.joinToString("\u0000") + "\n"
-
- try {
- processWriter?.write(argsLine)
- processWriter?.flush()
-
- val result = readDaemonResponse()
-
- if (result == null) {
- LOG.error("Daemon compilation timed out after {}ms", COMPILE_TIMEOUT_MS)
- stopDaemon()
- return@withContext CompilerResult(
- success = false,
- output = "",
- errorOutput = "Compilation timed out after ${COMPILE_TIMEOUT_MS / 1000} seconds"
- )
- }
-
- val (output, errors) = result
- scheduleIdleTimeout()
-
- val hasErrors = output.contains("error:") || errors.contains("error:")
-
- CompilerResult(
- success = !hasErrors && outputDir.walkTopDown().any { it.extension == "class" },
- output = output,
- errorOutput = errors
- )
- } catch (e: Exception) {
- LOG.error("Daemon compilation failed", e)
- stopDaemon()
- CompilerResult(success = false, output = "", errorOutput = e.message ?: "Unknown error")
- }
- }
- }
-
- suspend fun dex(
- classesDir: File,
- outputDir: File
- ): DexResult = mutex.withLock {
- withContext(Dispatchers.IO) {
- ensureDaemonRunning()
-
- outputDir.mkdirs()
-
- val classFiles = classesDir.walkTopDown()
- .filter { it.extension == "class" }
- .toList()
-
- if (classFiles.isEmpty()) {
- return@withContext DexResult(
- success = false,
- dexFile = null,
- errorOutput = "No .class files found in $classesDir"
- )
- }
-
- val d8Args = buildD8Args(classFiles, outputDir)
- val argsLine = "DEX\u0000" + d8Args.joinToString("\u0000") + "\n"
-
- try {
- processWriter?.write(argsLine)
- processWriter?.flush()
-
- val result = readDaemonResponse()
-
- if (result == null) {
- LOG.error("Daemon D8 timed out after {}ms", COMPILE_TIMEOUT_MS)
- stopDaemon()
- return@withContext DexResult(
- success = false,
- dexFile = null,
- errorOutput = "D8 timed out"
- )
- }
-
- val (output, errors) = result
- scheduleIdleTimeout()
-
- val dexFile = File(outputDir, "classes.dex")
- val success = output.contains("DEX_SUCCESS") && dexFile.exists()
-
- if (!success) {
- LOG.error("Daemon D8 failed: {} {}", output, errors)
- }
-
- DexResult(
- success = success,
- dexFile = if (success) dexFile else null,
- errorOutput = if (!success) (errors.ifEmpty { output }) else ""
- )
- } catch (e: Exception) {
- LOG.error("Daemon D8 failed", e)
- stopDaemon()
- DexResult(success = false, dexFile = null, errorOutput = e.message ?: "Unknown error")
- }
- }
- }
-
- private fun buildD8Args(classFiles: List, outputDir: File): List = buildList {
- add("--release")
- add("--min-api")
- add("21")
-
- classpathManager.getRuntimeJars()
- .filter { it.exists() }
- .forEach { jar ->
- add("--classpath")
- add(jar.absolutePath)
- }
-
- if (Environment.ANDROID_JAR.exists()) {
- add("--lib")
- add(Environment.ANDROID_JAR.absolutePath)
- }
-
- add("--output")
- add(outputDir.absolutePath)
-
- classFiles.forEach { add(it.absolutePath) }
- }
-
- private suspend fun readDaemonResponse(): Pair? {
- return withTimeoutOrNull(COMPILE_TIMEOUT_MS) {
- val response = StringBuilder()
- var line: String?
-
- while (true) {
- line = processReader?.readLine()
- if (line == null || line == "---END---") break
- response.appendLine(line)
- }
-
- val errorOutput = StringBuilder()
- while (errorReader?.ready() == true) {
- errorOutput.appendLine(errorReader?.readLine())
- }
-
- Pair(response.toString(), errorOutput.toString())
- }
- }
-
- private fun ensureDaemonRunning() {
- if (daemonProcess?.isAlive == true) {
- return
- }
-
- ensureWrapperCompiled()
- startDaemon()
- }
-
- private fun ensureWrapperCompiled() {
- val versionFile = File(wrapperDir, ".wrapper_version")
- val storedVersion = if (versionFile.exists()) versionFile.readText().trim().toIntOrNull() ?: 0 else 0
-
- if (wrapperClass.exists() && storedVersion == WRAPPER_VERSION) {
- return
- }
-
- wrapperClass.delete()
-
- LOG.info("Compiling daemon wrapper (v{})...", WRAPPER_VERSION)
-
- val wrapperSource = File(wrapperDir, "CompilerWrapper.java")
- wrapperSource.writeText(WRAPPER_SOURCE)
-
- val javac = File(Environment.JAVA.parentFile, "javac")
- val kotlinCompilerJar = classpathManager.getKotlinCompiler()
- ?: throw RuntimeException("Kotlin compiler not found in local Maven repository. Build any project first.")
-
- val command = listOf(
- javac.absolutePath,
- "-cp",
- kotlinCompilerJar.absolutePath,
- "-d",
- wrapperDir.absolutePath,
- wrapperSource.absolutePath
- )
-
- val process = ProcessBuilder(command)
- .redirectErrorStream(true)
- .start()
-
- val output = process.inputStream.bufferedReader().readText()
- val exitCode = process.waitFor()
-
- if (exitCode != 0) {
- LOG.error("Failed to compile wrapper: {}", output)
- throw RuntimeException("Failed to compile daemon wrapper: $output")
- }
-
- wrapperSource.delete()
- versionFile.writeText(WRAPPER_VERSION.toString())
- LOG.info("Daemon wrapper compiled successfully")
- }
-
- private fun startDaemon() {
- val javaExecutable = Environment.JAVA
-
- val d8JarPath = classpathManager.getD8Jar()?.absolutePath ?: ""
- val bootstrapClasspath = classpathManager.getCompilerBootstrapClasspath() +
- File.pathSeparator + wrapperDir.absolutePath +
- (if (d8JarPath.isNotEmpty()) File.pathSeparator + d8JarPath else "")
-
- val command = listOf(
- javaExecutable.absolutePath,
- "-Xmx512m",
- "-cp",
- bootstrapClasspath,
- "CompilerWrapper"
- )
-
- LOG.info("Starting compiler daemon...")
-
- val processBuilder = ProcessBuilder(command)
- .directory(workDir)
- .redirectErrorStream(false)
-
- daemonProcess = processBuilder.start()
- processWriter = OutputStreamWriter(daemonProcess!!.outputStream)
- processReader = BufferedReader(InputStreamReader(daemonProcess!!.inputStream))
- errorReader = BufferedReader(InputStreamReader(daemonProcess!!.errorStream))
-
- val ready = processReader?.readLine()
- if (ready == "READY") {
- LOG.info("Compiler daemon started and ready")
- scheduleIdleTimeout()
- } else {
- LOG.error("Daemon failed to start, got: {}", ready)
- stopDaemon()
- throw RuntimeException("Daemon failed to start")
- }
- }
-
- private fun scheduleIdleTimeout() {
- idleTimeoutJob?.cancel()
- idleTimeoutJob = timeoutScope.launch {
- delay(IDLE_TIMEOUT_MS)
- mutex.withLock {
- if (daemonProcess?.isAlive == true) {
- LOG.info("Stopping idle compiler daemon after {}ms", IDLE_TIMEOUT_MS)
- stopDaemon()
- }
- }
- }
- }
-
- fun stopDaemon() {
- idleTimeoutJob?.cancel()
- idleTimeoutJob = null
-
- val process = daemonProcess
- val writer = processWriter
- val reader = processReader
- val errReader = errorReader
-
- daemonProcess = null
- processWriter = null
- processReader = null
- errorReader = null
-
- if (process == null && writer == null && reader == null && errReader == null) {
- return
- }
-
- Thread({
- try {
- writer?.write("EXIT\n")
- writer?.flush()
- process?.waitFor(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)
- } catch (e: Exception) {
- LOG.debug("Error sending EXIT to daemon", e)
- }
-
- try {
- writer?.close()
- reader?.close()
- errReader?.close()
- process?.destroyForcibly()
- } catch (e: Exception) {
- LOG.warn("Error stopping daemon", e)
- }
- }, "compose-daemon-shutdown").apply { isDaemon = true }.start()
- }
-
- fun shutdown() {
- stopDaemon()
- timeoutScope.cancel()
- }
-
- suspend fun startEagerly() = mutex.withLock {
- withContext(Dispatchers.IO) {
- if (daemonProcess?.isAlive == true) return@withContext
- isStartingUp = true
- try {
- ensureDaemonRunning()
- } finally {
- isStartingUp = false
- }
- }
- }
-
- data class CompilerResult(
- val success: Boolean,
- val output: String,
- val errorOutput: String
- )
-
- data class DexResult(
- val success: Boolean,
- val dexFile: File?,
- val errorOutput: String = ""
- )
-
- companion object {
- private val LOG = LoggerFactory.getLogger(CompilerDaemon::class.java)
-
- private const val IDLE_TIMEOUT_MS = 120_000L
- private const val SHUTDOWN_TIMEOUT_SECONDS = 5L
- private const val COMPILE_TIMEOUT_MS = 300_000L
- private const val WRAPPER_VERSION = 2
-
- private val WRAPPER_SOURCE = """
- import java.io.*;
- import java.lang.reflect.*;
- import java.util.Arrays;
-
- public class CompilerWrapper {
- private static Object kotlinCompiler;
- private static Method kotlinExecMethod;
- private static Method d8ParseMethod;
- private static Method d8RunMethod;
- private static Class> d8CommandClass;
-
- public static void main(String[] args) throws Exception {
- Class> compilerClass = Class.forName("org.jetbrains.kotlin.cli.jvm.K2JVMCompiler");
- kotlinCompiler = compilerClass.getDeclaredConstructor().newInstance();
- kotlinExecMethod = compilerClass.getMethod("exec", PrintStream.class, String[].class);
-
- BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
- System.out.println("READY");
- System.out.flush();
-
- String line;
- while ((line = reader.readLine()) != null) {
- if (line.equals("EXIT")) {
- break;
- }
-
- String[] parts = line.split("\u0000");
- String command = parts[0];
-
- try {
- if (command.equals("DEX")) {
- String[] d8Args = Arrays.copyOfRange(parts, 1, parts.length);
- handleDex(d8Args);
- } else if (command.equals("COMPILE")) {
- String[] compilerArgs = Arrays.copyOfRange(parts, 1, parts.length);
- handleCompile(compilerArgs);
- } else {
- handleCompile(parts);
- }
- } catch (Exception e) {
- System.out.println("ERROR:" + e.getMessage());
- e.printStackTrace(System.out);
- }
-
- System.out.println("---END---");
- System.out.flush();
- }
- }
-
- private static void handleCompile(String[] compilerArgs) throws Exception {
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- PrintStream ps = new PrintStream(baos);
- Object result = kotlinExecMethod.invoke(kotlinCompiler, ps, compilerArgs);
- ps.flush();
- String output = baos.toString();
- if (!output.isEmpty()) {
- System.out.print(output);
- }
- System.out.println("EXIT_CODE:" + result);
- }
-
- private static void handleDex(String[] d8Args) throws Exception {
- if (d8CommandClass == null) {
- d8CommandClass = Class.forName("com.android.tools.r8.D8Command");
- d8ParseMethod = d8CommandClass.getMethod("parse", String[].class);
- Class> d8Class = Class.forName("com.android.tools.r8.D8");
- d8RunMethod = d8Class.getMethod("run", d8CommandClass);
- }
-
- Object cmd = d8ParseMethod.invoke(null, (Object) d8Args);
- d8RunMethod.invoke(null, cmd);
- System.out.println("DEX_SUCCESS");
- }
- }
- """.trimIndent()
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeClasspathManager.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeClasspathManager.kt
deleted file mode 100644
index bb638a7846..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeClasspathManager.kt
+++ /dev/null
@@ -1,327 +0,0 @@
-package com.itsaky.androidide.compose.preview.compiler
-
-import android.content.Context
-import com.itsaky.androidide.utils.Environment
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import org.slf4j.LoggerFactory
-import java.io.BufferedReader
-import java.io.File
-import java.io.InputStreamReader
-import java.util.concurrent.TimeUnit
-import java.util.zip.ZipInputStream
-
-class ComposeClasspathManager(private val context: Context) {
-
- private val composeDir: File
- get() = Environment.COMPOSE_HOME
-
- companion object {
- private val LOG = LoggerFactory.getLogger(ComposeClasspathManager::class.java)
-
- private const val D8_HEAP_SIZE = "512m"
- private const val MIN_API_LEVEL = "21"
- private const val D8_TIMEOUT_MINUTES = 5L
- }
-
- private val runtimeDexDir: File
- get() = File(composeDir, "dex")
-
- private val localMavenRepo: File
- get() = File(Environment.HOME, "maven/localMvnRepository")
-
- private val dexMutex = Mutex()
-
- private val kotlinArtifacts = mapOf(
- "kotlin-compiler" to "org/jetbrains/kotlin/kotlin-compiler-embeddable",
- "kotlin-stdlib" to "org/jetbrains/kotlin/kotlin-stdlib",
- "kotlin-reflect" to "org/jetbrains/kotlin/kotlin-reflect",
- "kotlin-script-runtime" to "org/jetbrains/kotlin/kotlin-script-runtime",
- "trove4j" to "org/jetbrains/intellij/deps/trove4j",
- "annotations" to "org/jetbrains/annotations"
- )
-
- private val requiredRuntimeJarPatterns = listOf(
- "compose-compiler-plugin.jar",
- Regex("runtime-release\\.jar"),
- Regex("ui-release\\.jar"),
- Regex("animation-release\\.jar"),
- Regex("animation-core-release\\.jar"),
- Regex("foundation-release\\.jar"),
- Regex("material3-release\\.jar")
- )
-
- fun ensureComposeJarsExtracted(): Boolean {
- val extracted = areRuntimeJarsExtracted()
- LOG.info("Compose runtime JARs extracted: {}, dir: {}", extracted, composeDir.absolutePath)
-
- if (extracted) {
- LOG.debug("Compose runtime JARs already extracted")
- return true
- }
-
- return try {
- composeDir.deleteRecursively()
- extractComposeJars()
- true
- } catch (e: Exception) {
- LOG.error("Failed to extract Compose JARs", e)
- false
- }
- }
-
- fun isKotlinCompilerAvailable(): Boolean {
- val compiler = findMavenJar("kotlin-compiler")
- val available = compiler?.exists() == true
- LOG.info("Kotlin compiler available in local Maven repo: {}", available)
- return available
- }
-
- private fun areRuntimeJarsExtracted(): Boolean {
- if (!composeDir.exists()) return false
-
- val files = composeDir.listFiles()?.map { it.name } ?: return false
-
- return requiredRuntimeJarPatterns.all { pattern ->
- when (pattern) {
- is String -> files.contains(pattern)
- is Regex -> files.any { pattern.matches(it) }
- else -> false
- }
- }
- }
-
- private fun findMavenJar(artifactKey: String): File? {
- val artifactPath = kotlinArtifacts[artifactKey] ?: return null
- val artifactDir = File(localMavenRepo, artifactPath)
-
- if (!artifactDir.exists()) {
- LOG.debug("Maven artifact dir not found: {}", artifactDir)
- return null
- }
-
- val versionDirs = artifactDir.listFiles { file -> file.isDirectory }
- ?.sortedByDescending { it.name }
- ?: return null
-
- for (versionDir in versionDirs) {
- val jars = versionDir.listFiles { file ->
- file.extension == "jar" && !file.name.contains("-sources") && !file.name.contains("-javadoc")
- }
- if (!jars.isNullOrEmpty()) {
- LOG.debug("Found {} in local Maven repo: {}", artifactKey, jars[0])
- return jars[0]
- }
- }
-
- return null
- }
-
- private fun extractComposeJars() {
- composeDir.mkdirs()
- val composeDirPath = composeDir.canonicalPath
-
- context.assets.open("compose/compose-jars.zip").use { input ->
- ZipInputStream(input).use { zip ->
- var entry = zip.nextEntry
- while (entry != null) {
- if (entry.isDirectory) {
- zip.closeEntry()
- entry = zip.nextEntry
- continue
- }
-
- val file = File(composeDir, entry.name).canonicalFile
- if (!file.path.startsWith(composeDirPath)) {
- LOG.warn("Skipping zip entry with invalid path: {}", entry.name)
- zip.closeEntry()
- entry = zip.nextEntry
- continue
- }
-
- file.parentFile?.mkdirs()
- file.outputStream().use { output ->
- zip.copyTo(output)
- }
-
- zip.closeEntry()
- entry = zip.nextEntry
- }
- }
- }
- LOG.info("Extracted Compose JARs to {}", composeDir)
- }
-
- fun getKotlinCompiler(): File? {
- return findMavenJar("kotlin-compiler")
- }
-
- fun getCompilerPlugin(): File {
- return File(composeDir, "compose-compiler-plugin.jar")
- }
-
- fun getKotlinStdlib(): File? {
- return findMavenJar("kotlin-stdlib")
- }
-
- fun getCompilerBootstrapClasspath(): String {
- val jars = buildList {
- findMavenJar("kotlin-compiler")?.let { add(it) }
- findMavenJar("kotlin-stdlib")?.let { add(it) }
- findMavenJar("kotlin-reflect")?.let { add(it) }
- findMavenJar("kotlin-script-runtime")?.let { add(it) }
- findMavenJar("trove4j")?.let { add(it) }
- findMavenJar("annotations")?.let { add(it) }
- }
- return jars.filter { it.exists() }
- .joinToString(File.pathSeparator) { it.absolutePath }
- }
-
- fun getRuntimeJars(): List {
- val compilerPlugin = getCompilerPlugin()
- return composeDir.listFiles { file ->
- file.extension == "jar" && file != compilerPlugin
- }?.toList() ?: emptyList()
- }
-
- fun getAllJars(): List {
- return buildList {
- addAll(getRuntimeJars())
- findMavenJar("kotlin-stdlib")?.let { add(it) }
- }
- }
-
- fun getFullClasspath(): List {
- return buildList {
- add(Environment.ANDROID_JAR)
- addAll(getAllJars())
- }
- }
-
- fun getCompilationClasspath(additionalJars: List = emptyList()): String {
- val base = getFullClasspath()
- val extra = additionalJars.filter { it.exists() }
- val missingExtra = additionalJars.filter { !it.exists() }
- val all = (base + extra).filter { it.exists() }
- val classpath = all.joinToString(File.pathSeparator) { it.absolutePath }
- LOG.info("Compilation classpath has {} JARs ({} bundled, {} project, {} missing)", all.size, base.count { it.exists() }, extra.size, missingExtra.size)
- return classpath
- }
-
- fun getD8Jar(): File? = findD8Jar()
-
- suspend fun getOrCreateRuntimeDex(): File? = dexMutex.withLock {
- withContext(Dispatchers.IO) {
- LOG.info("getOrCreateRuntimeDex called, runtimeDexDir={}", runtimeDexDir.absolutePath)
- val runtimeDex = File(runtimeDexDir, "compose-runtime.dex")
-
- if (runtimeDex.exists()) {
- LOG.info("Using cached Compose runtime DEX: {}", runtimeDex.absolutePath)
- return@withContext runtimeDex
- }
-
- LOG.info("Creating Compose runtime DEX (one-time operation)...")
-
- val runtimeJars = getRuntimeJars()
- if (runtimeJars.isEmpty()) {
- LOG.error("No runtime JARs found to dex")
- return@withContext null
- }
-
- val d8Jar = findD8Jar()
- if (d8Jar == null) {
- LOG.error("D8 jar not found")
- return@withContext null
- }
-
- val javaExecutable = Environment.JAVA
- if (!javaExecutable.exists()) {
- LOG.error("Java executable not found")
- return@withContext null
- }
-
- runtimeDexDir.mkdirs()
-
- val command = buildList {
- add(javaExecutable.absolutePath)
- add("-Xmx$D8_HEAP_SIZE")
- add("-cp")
- add(d8Jar.absolutePath)
- add("com.android.tools.r8.D8")
- add("--release")
- add("--min-api")
- add(MIN_API_LEVEL)
- add("--lib")
- add(Environment.ANDROID_JAR.absolutePath)
- add("--output")
- add(runtimeDexDir.absolutePath)
- runtimeJars.forEach { jar ->
- add(jar.absolutePath)
- }
- }
-
- LOG.info("Running D8 for runtime JARs: {} JARs", runtimeJars.size)
-
- try {
- val process = ProcessBuilder(command)
- .redirectErrorStream(true)
- .start()
-
- val outputDeferred = async {
- BufferedReader(InputStreamReader(process.inputStream)).use { it.readText() }
- }
-
- val completed = process.waitFor(D8_TIMEOUT_MINUTES, TimeUnit.MINUTES)
- val output = outputDeferred.await()
-
- if (!completed) {
- process.destroyForcibly()
- LOG.error("D8 timed out after {} minutes. Output: {}", D8_TIMEOUT_MINUTES, output)
- return@withContext null
- }
-
- val exitCode = process.exitValue()
- val outputDex = File(runtimeDexDir, "classes.dex")
- if (exitCode == 0 && outputDex.exists()) {
- outputDex.renameTo(runtimeDex)
- LOG.info("Compose runtime DEX created successfully")
- return@withContext runtimeDex
- } else {
- LOG.error("D8 failed for runtime. Exit: {}, output: {}", exitCode, output)
- return@withContext null
- }
- } catch (e: Exception) {
- LOG.error("Failed to create runtime DEX", e)
- return@withContext null
- }
- }
- }
-
- private fun findD8Jar(): File? {
- val buildToolsDir = File(Environment.ANDROID_HOME, "build-tools")
- if (!buildToolsDir.exists()) {
- LOG.warn("Build tools directory not found: {}", buildToolsDir)
- return null
- }
-
- val installedVersions = buildToolsDir.listFiles()
- ?.filter { it.isDirectory }
- ?.sortedByDescending { it.name }
- ?: emptyList()
-
- for (versionDir in installedVersions) {
- val d8Jar = File(versionDir, "lib/d8.jar")
- if (d8Jar.exists()) {
- LOG.debug("Using D8 from build-tools {}", versionDir.name)
- return d8Jar
- }
- }
-
- LOG.warn("D8 jar not found in any installed build-tools version")
- return null
- }
-
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeCompiler.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeCompiler.kt
deleted file mode 100644
index bad6303062..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeCompiler.kt
+++ /dev/null
@@ -1,236 +0,0 @@
-package com.itsaky.androidide.compose.preview.compiler
-
-import com.itsaky.androidide.utils.Environment
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.withContext
-import org.slf4j.LoggerFactory
-import java.io.BufferedReader
-import java.io.File
-import java.io.InputStreamReader
-import java.util.concurrent.TimeUnit
-
-data class CompilationResult(
- val success: Boolean,
- val outputDir: File?,
- val diagnostics: List,
- val errorOutput: String = ""
-)
-
-data class CompileDiagnostic(
- val severity: Severity,
- val message: String,
- val file: String?,
- val line: Int?,
- val column: Int?
-) {
- enum class Severity { ERROR, WARNING, INFO }
-}
-
-private val compilerArgsLog = LoggerFactory.getLogger("ComposeCompilerArgs")
-
-internal fun buildCompilerArgs(
- sourceFiles: List,
- outputDir: File,
- classpath: String,
- composePlugin: File
-): List = buildList {
- if (composePlugin.exists()) {
- compilerArgsLog.info("Using Compose compiler plugin: {}", composePlugin.absolutePath)
- add("-Xplugin=${composePlugin.absolutePath}")
- } else {
- compilerArgsLog.warn("Compose compiler plugin NOT found at: {}", composePlugin.absolutePath)
- }
-
- add("-classpath")
- add(classpath)
-
- add("-d")
- add(outputDir.absolutePath)
-
- add("-jvm-target")
- add("1.8")
-
- add("-no-stdlib")
-
- add("-Xskip-metadata-version-check")
-
- sourceFiles.forEach { file ->
- add(file.absolutePath)
- }
-}
-
-class ComposeCompiler(
- private val classpathManager: ComposeClasspathManager,
- private val workDir: File
-) {
- private val incrementalCacheDir = File(workDir, "ic-cache").apply { mkdirs() }
-
- suspend fun compile(
- sourceFiles: List,
- outputDir: File,
- additionalClasspaths: List = emptyList()
- ): CompilationResult =
- withContext(Dispatchers.IO) {
- outputDir.mkdirs()
-
- val classpath = classpathManager.getCompilationClasspath(additionalClasspaths)
- val kotlinCompiler = classpathManager.getKotlinCompiler()
- val composePlugin = classpathManager.getCompilerPlugin()
- val compilerBootstrapClasspath = classpathManager.getCompilerBootstrapClasspath()
-
- if (kotlinCompiler == null || !kotlinCompiler.exists()) {
- return@withContext CompilationResult(
- success = false,
- outputDir = null,
- diagnostics = listOf(
- CompileDiagnostic(
- CompileDiagnostic.Severity.ERROR,
- "Kotlin compiler not found in local Maven repository. Build any project first.",
- null, null, null
- )
- )
- )
- }
-
- val args = buildCompilerArgs(
- sourceFiles = sourceFiles,
- outputDir = outputDir,
- classpath = classpath,
- composePlugin = composePlugin
- )
-
- LOG.info("Compiling with args: {}", args.joinToString(" "))
-
- try {
- val result = invokeKotlinCompiler(compilerBootstrapClasspath, args)
- parseCompilationResult(result, outputDir)
- } catch (e: Exception) {
- LOG.error("Compilation failed", e)
- CompilationResult(
- success = false,
- outputDir = null,
- diagnostics = listOf(
- CompileDiagnostic(
- CompileDiagnostic.Severity.ERROR,
- "Compilation exception: ${e.message}",
- null, null, null
- )
- ),
- errorOutput = e.stackTraceToString()
- )
- }
- }
-
- private suspend fun invokeKotlinCompiler(
- compilerBootstrapClasspath: String,
- args: List
- ): ProcessResult {
- val javaExecutable = Environment.JAVA
-
- if (!javaExecutable.exists()) {
- LOG.error("Java executable not found at: {}", javaExecutable.absolutePath)
- return ProcessResult(-1, "", "Java executable not found at: ${javaExecutable.absolutePath}")
- }
-
- if (compilerBootstrapClasspath.isEmpty()) {
- LOG.error("Compiler bootstrap classpath is empty")
- return ProcessResult(-1, "", "Compiler bootstrap classpath is empty")
- }
-
- val command = buildList {
- add(javaExecutable.absolutePath)
- add("-cp")
- add(compilerBootstrapClasspath)
- add("org.jetbrains.kotlin.cli.jvm.K2JVMCompiler")
- addAll(args)
- }
-
- LOG.debug("Running: {}", command.joinToString(" "))
-
- val processBuilder = ProcessBuilder(command)
- .directory(workDir)
- .redirectErrorStream(true)
-
- val process = processBuilder.start()
-
- return coroutineScope {
- val outputDeferred = async {
- BufferedReader(InputStreamReader(process.inputStream)).use { it.readText() }
- }
-
- val completed = process.waitFor(COMPILATION_TIMEOUT_MINUTES, TimeUnit.MINUTES)
-
- if (!completed) {
- process.destroyForcibly()
- val output = outputDeferred.await()
- LOG.error("Compilation timed out after {} minutes", COMPILATION_TIMEOUT_MINUTES)
- return@coroutineScope ProcessResult(-1, output, "Compilation timed out after $COMPILATION_TIMEOUT_MINUTES minutes")
- }
-
- val output = outputDeferred.await()
- ProcessResult(process.exitValue(), output, output)
- }
- }
-
- private fun parseCompilationResult(
- processResult: ProcessResult,
- outputDir: File
- ): CompilationResult {
- val diagnostics = mutableListOf()
-
- val combinedOutput = processResult.stderr + processResult.stdout
- val diagnosticRegex = Regex("""(.+):(\d+):(\d+): (error|warning): (.+)""")
-
- combinedOutput.lines().forEach { line ->
- val match = diagnosticRegex.find(line)
- if (match != null) {
- val (file, lineNum, col, severity, message) = match.destructured
- diagnostics.add(
- CompileDiagnostic(
- severity = when (severity) {
- "error" -> CompileDiagnostic.Severity.ERROR
- "warning" -> CompileDiagnostic.Severity.WARNING
- else -> CompileDiagnostic.Severity.INFO
- },
- message = message,
- file = file,
- line = lineNum.toIntOrNull(),
- column = col.toIntOrNull()
- )
- )
- } else if (line.contains("error:", ignoreCase = true)) {
- diagnostics.add(
- CompileDiagnostic(
- CompileDiagnostic.Severity.ERROR,
- line,
- null, null, null
- )
- )
- }
- }
-
- val hasErrors = diagnostics.any { it.severity == CompileDiagnostic.Severity.ERROR }
- val hasClassFiles = outputDir.walkTopDown().any { it.extension == "class" }
- val success = processResult.exitCode == 0 && !hasErrors && hasClassFiles
-
- return CompilationResult(
- success = success,
- outputDir = if (success) outputDir else null,
- diagnostics = diagnostics,
- errorOutput = if (!success) processResult.stderr else ""
- )
- }
-
- private data class ProcessResult(
- val exitCode: Int,
- val stdout: String,
- val stderr: String
- )
-
- companion object {
- private val LOG = LoggerFactory.getLogger(ComposeCompiler::class.java)
- private const val COMPILATION_TIMEOUT_MINUTES = 5L
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeDexCompiler.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeDexCompiler.kt
deleted file mode 100644
index b01df20492..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeDexCompiler.kt
+++ /dev/null
@@ -1,150 +0,0 @@
-package com.itsaky.androidide.compose.preview.compiler
-
-import com.itsaky.androidide.utils.Environment
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.withContext
-import org.slf4j.LoggerFactory
-import java.io.BufferedReader
-import java.io.File
-import java.io.InputStreamReader
-import java.util.concurrent.TimeUnit
-
-data class DexCompilationResult(
- val success: Boolean,
- val dexFile: File?,
- val errorMessage: String = ""
-)
-
-class ComposeDexCompiler(
- private val classpathManager: ComposeClasspathManager
-) {
-
- suspend fun compileToDex(classesDir: File, outputDir: File): DexCompilationResult =
- withContext(Dispatchers.IO) {
- outputDir.mkdirs()
-
- val d8Jar = classpathManager.getD8Jar()
- if (d8Jar == null || !d8Jar.exists()) {
- return@withContext DexCompilationResult(
- success = false,
- dexFile = null,
- errorMessage = "D8 jar not found"
- )
- }
-
- val javaExecutable = Environment.JAVA
- if (!javaExecutable.exists()) {
- return@withContext DexCompilationResult(
- success = false,
- dexFile = null,
- errorMessage = "Java executable not found"
- )
- }
-
- val classFiles = classesDir.walkTopDown()
- .filter { it.extension == "class" }
- .toList()
-
- if (classFiles.isEmpty()) {
- return@withContext DexCompilationResult(
- success = false,
- dexFile = null,
- errorMessage = "No .class files found in $classesDir"
- )
- }
-
- val command = buildD8Command(javaExecutable, d8Jar, classFiles, outputDir)
-
- LOG.info("Running D8: {}", command.joinToString(" "))
-
- try {
- val processBuilder = ProcessBuilder(command)
- .directory(classesDir)
- .redirectErrorStream(false)
-
- val process = processBuilder.start()
-
- val stdoutDeferred = async {
- BufferedReader(InputStreamReader(process.inputStream)).use { it.readText() }
- }
- val stderrDeferred = async {
- BufferedReader(InputStreamReader(process.errorStream)).use { it.readText() }
- }
-
- val completed = process.waitFor(DEX_TIMEOUT_MINUTES, TimeUnit.MINUTES)
-
- val stdout = stdoutDeferred.await()
- val stderr = stderrDeferred.await()
-
- if (!completed) {
- process.destroyForcibly()
- LOG.error("D8 timed out after {} minutes. stdout: {}, stderr: {}", DEX_TIMEOUT_MINUTES, stdout, stderr)
- return@withContext DexCompilationResult(
- success = false,
- dexFile = null,
- errorMessage = "D8 timed out after $DEX_TIMEOUT_MINUTES minutes"
- )
- }
-
- val dexFile = File(outputDir, "classes.dex")
- val success = process.exitValue() == 0 && dexFile.exists()
-
- if (!success) {
- LOG.error("D8 failed. Exit: {}, stderr: {}", process.exitValue(), stderr)
- }
-
- DexCompilationResult(
- success = success,
- dexFile = if (success) dexFile else null,
- errorMessage = if (!success) stderr.ifEmpty { stdout } else ""
- )
- } catch (e: Exception) {
- LOG.error("D8 execution failed", e)
- DexCompilationResult(
- success = false,
- dexFile = null,
- errorMessage = "D8 execution failed: ${e.message}"
- )
- }
- }
-
- private fun buildD8Command(
- javaExecutable: File,
- d8Jar: File,
- classFiles: List,
- outputDir: File
- ): List = buildList {
- add(javaExecutable.absolutePath)
- add("-cp")
- add(d8Jar.absolutePath)
- add("com.android.tools.r8.D8")
- add("--release")
- add("--min-api")
- add("21")
-
- classpathManager.getRuntimeJars()
- .filter { it.exists() }
- .forEach { jar ->
- add("--classpath")
- add(jar.absolutePath)
- }
-
- if (Environment.ANDROID_JAR.exists()) {
- add("--lib")
- add(Environment.ANDROID_JAR.absolutePath)
- }
-
- add("--output")
- add(outputDir.absolutePath)
-
- classFiles.forEach { classFile ->
- add(classFile.absolutePath)
- }
- }
-
- companion object {
- private val LOG = LoggerFactory.getLogger(ComposeDexCompiler::class.java)
- private const val DEX_TIMEOUT_MINUTES = 5L
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/DexCache.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/DexCache.kt
deleted file mode 100644
index 618322d9df..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/DexCache.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-package com.itsaky.androidide.compose.preview.compiler
-
-import org.slf4j.LoggerFactory
-import java.io.File
-import java.security.MessageDigest
-
-class DexCache(private val cacheDir: File) {
-
- init {
- cacheDir.mkdirs()
- }
-
- fun getCachedDex(sourceHash: String): CachedDexResult? {
- val cacheEntry = File(cacheDir, "$sourceHash.dex")
- val metaFile = File(cacheDir, "$sourceHash.meta")
-
- if (!cacheEntry.exists() || !metaFile.exists()) {
- return null
- }
-
- val meta = metaFile.readLines()
- if (meta.size < 2) {
- cacheEntry.delete()
- metaFile.delete()
- return null
- }
-
- LOG.debug("Cache hit for hash: {}", sourceHash)
- return CachedDexResult(
- dexFile = cacheEntry,
- className = meta[0],
- functionName = meta[1]
- )
- }
-
- fun cacheDex(
- sourceHash: String,
- dexFile: File,
- className: String,
- functionName: String
- ) {
- val cacheEntry = File(cacheDir, "$sourceHash.dex")
- val metaFile = File(cacheDir, "$sourceHash.meta")
-
- dexFile.copyTo(cacheEntry, overwrite = true)
- metaFile.writeText("$className\n$functionName")
-
- LOG.debug("Cached DEX for hash: {}", sourceHash)
- cleanOldEntries()
- }
-
- fun computeSourceHash(source: String): String {
- val digest = MessageDigest.getInstance("SHA-256")
- return digest.digest(source.toByteArray())
- .joinToString("") { "%02x".format(it) }
- }
-
- private fun cleanOldEntries() {
- val entries = cacheDir.listFiles { file -> file.extension == "dex" } ?: return
- if (entries.size <= MAX_CACHE_ENTRIES) return
-
- var deletedCount = 0
- entries
- .sortedBy { it.lastModified() }
- .take(entries.size - MAX_CACHE_ENTRIES)
- .forEach { entry ->
- val metaFile = File(entry.parent, "${entry.nameWithoutExtension}.meta")
- val dexDeleted = entry.delete()
- val metaDeleted = metaFile.delete()
- if (dexDeleted) {
- deletedCount++
- } else {
- LOG.warn("Failed to delete cache entry: {}", entry.absolutePath)
- }
- if (metaFile.exists() && !metaDeleted) {
- LOG.warn("Failed to delete cache meta: {}", metaFile.absolutePath)
- }
- }
-
- LOG.debug("Cleaned {} old cache entries, kept {}", deletedCount, MAX_CACHE_ENTRIES)
- }
-
- fun clearCache() {
- cacheDir.listFiles()?.forEach { it.delete() }
- LOG.info("Cache cleared")
- }
-
- data class CachedDexResult(
- val dexFile: File,
- val className: String,
- val functionName: String
- )
-
- companion object {
- private val LOG = LoggerFactory.getLogger(DexCache::class.java)
- private const val MAX_CACHE_ENTRIES = 20
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/repository/ComposePreviewRepository.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/repository/ComposePreviewRepository.kt
deleted file mode 100644
index 63df5aa51f..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/repository/ComposePreviewRepository.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.itsaky.androidide.compose.preview.data.repository
-
-import android.content.Context
-import com.itsaky.androidide.compose.preview.compiler.CompileDiagnostic
-import com.itsaky.androidide.compose.preview.data.source.ProjectContext
-import com.itsaky.androidide.compose.preview.domain.model.ParsedPreviewSource
-import java.io.File
-
-interface ComposePreviewRepository {
-
- suspend fun initialize(context: Context, filePath: String): Result
-
- suspend fun compilePreview(
- source: String,
- parsedSource: ParsedPreviewSource
- ): Result
-
- fun computeSourceHash(source: String): String
-
- fun reset()
-}
-
-sealed class InitializationResult {
- data class Ready(
- val runtimeDex: File?,
- val projectContext: ProjectContext
- ) : InitializationResult()
-
- data class NeedsBuild(
- val modulePath: String,
- val variantName: String
- ) : InitializationResult()
-
- data class Failed(val message: String) : InitializationResult()
-}
-
-data class CompilationResult(
- val dexFile: File,
- val className: String,
- val runtimeDex: File?,
- val projectDexFiles: List
-)
-
-class CompilationException(
- message: String,
- val diagnostics: List = emptyList()
-) : Exception(message)
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/repository/ComposePreviewRepositoryImpl.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/repository/ComposePreviewRepositoryImpl.kt
deleted file mode 100644
index 2d2b3b62d8..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/repository/ComposePreviewRepositoryImpl.kt
+++ /dev/null
@@ -1,276 +0,0 @@
-package com.itsaky.androidide.compose.preview.data.repository
-
-import android.content.Context
-import com.itsaky.androidide.compose.preview.compiler.CompileDiagnostic
-import com.itsaky.androidide.compose.preview.compiler.CompilerDaemon
-import com.itsaky.androidide.compose.preview.compiler.ComposeClasspathManager
-import com.itsaky.androidide.compose.preview.compiler.ComposeCompiler
-import com.itsaky.androidide.compose.preview.compiler.ComposeDexCompiler
-import com.itsaky.androidide.compose.preview.compiler.DexCache
-import com.itsaky.androidide.compose.preview.data.source.ProjectContext
-import com.itsaky.androidide.compose.preview.data.source.ProjectContextSource
-import com.itsaky.androidide.compose.preview.domain.model.ParsedPreviewSource
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import org.slf4j.LoggerFactory
-import java.io.File
-
-class ComposePreviewRepositoryImpl(
- private val projectContextSource: ProjectContextSource = ProjectContextSource()
-) : ComposePreviewRepository {
-
- private var classpathManager: ComposeClasspathManager? = null
- private var compiler: ComposeCompiler? = null
- private var compilerDaemon: CompilerDaemon? = null
- private var dexCompiler: ComposeDexCompiler? = null
- private var dexCache: DexCache? = null
- private var workDir: File? = null
-
- private var runtimeDex: File? = null
- private var projectContext: ProjectContext? = null
- private var daemonInitialized = false
- private var cachedClasspath: String? = null
-
- companion object {
- private val LOG = LoggerFactory.getLogger(ComposePreviewRepositoryImpl::class.java)
- }
-
- override suspend fun initialize(
- context: Context,
- filePath: String
- ): Result = withContext(Dispatchers.IO) {
- runCatching {
- val ctx = projectContextSource.resolveContext(filePath)
- projectContext = ctx
-
- if (ctx.needsBuild && ctx.modulePath != null) {
- LOG.warn("No intermediate classes found - build required before initialization")
- return@runCatching InitializationResult.NeedsBuild(ctx.modulePath, ctx.variantName)
- }
-
- val cpManager = initializeInfrastructure(context)
-
- if (!cpManager.ensureComposeJarsExtracted()) {
- return@runCatching InitializationResult.Failed(
- "Failed to initialize Compose dependencies"
- )
- }
-
- runtimeDex = cpManager.getOrCreateRuntimeDex()
- if (runtimeDex == null) {
- LOG.error("Failed to create Compose runtime DEX")
- return@runCatching InitializationResult.Failed(
- "Failed to create Compose runtime. Check that Android SDK build-tools are installed."
- )
- }
-
- LOG.info("Compose runtime DEX ready: {}", runtimeDex?.absolutePath)
-
- try {
- compilerDaemon?.startEagerly()
- LOG.info("Compiler daemon pre-started")
- } catch (e: Exception) {
- LOG.warn("Failed to pre-start compiler daemon (non-fatal)", e)
- }
-
- LOG.info("Repository initialized, runtimeDex={}", runtimeDex?.absolutePath ?: "null")
- InitializationResult.Ready(runtimeDex, ctx)
- }
- }
-
- private fun initializeInfrastructure(context: Context): ComposeClasspathManager {
- val cacheDir = context.cacheDir
- val work = File(cacheDir, "compose_preview_work").apply { mkdirs() }
- workDir = work
-
- dexCache = DexCache(File(cacheDir, "compose_dex_cache"))
-
- val cpManager = ComposeClasspathManager(context)
- classpathManager = cpManager
- compiler = ComposeCompiler(cpManager, work)
- compilerDaemon = CompilerDaemon(cpManager, work)
- dexCompiler = ComposeDexCompiler(cpManager)
- return cpManager
- }
-
- private fun requireInitialized(value: T?, name: String): T {
- return value ?: throw IllegalStateException("Repository not initialized: $name is null. Call initialize() first.")
- }
-
- private data class SourceCompileResult(
- val success: Boolean,
- val error: String,
- val diagnostics: List = emptyList()
- )
-
- override suspend fun compilePreview(
- source: String,
- parsedSource: ParsedPreviewSource
- ): Result = withContext(Dispatchers.IO) {
- runCatching {
- val cache = requireInitialized(dexCache, "dexCache")
- val compiler = requireInitialized(this@ComposePreviewRepositoryImpl.compiler, "compiler")
- val compilerDaemon = this@ComposePreviewRepositoryImpl.compilerDaemon
- val dexCompiler = requireInitialized(this@ComposePreviewRepositoryImpl.dexCompiler, "dexCompiler")
- val workDir = requireInitialized(this@ComposePreviewRepositoryImpl.workDir, "workDir")
- val classpathManager = requireInitialized(this@ComposePreviewRepositoryImpl.classpathManager, "classpathManager")
- val context = requireInitialized(projectContext, "projectContext")
-
- val fileName = parsedSource.className?.removeSuffix("Kt") ?: "Preview"
- val generatedClassName = "${fileName}Kt"
- val fullClassName = "${parsedSource.packageName}.$generatedClassName"
-
- val sourceHash = cache.computeSourceHash(source)
-
- val cached = cache.getCachedDex(sourceHash)
- if (cached != null) {
- LOG.info("Using cached DEX for hash: {}, runtimeDex={}, projectDexFiles={}",
- sourceHash, runtimeDex?.absolutePath ?: "null", context.projectDexFiles.size)
- return@runCatching CompilationResult(
- dexFile = cached.dexFile,
- className = fullClassName,
- runtimeDex = runtimeDex,
- projectDexFiles = context.projectDexFiles
- )
- }
-
- val sourceDir = File(workDir, "src")
- val packageDir = File(sourceDir, parsedSource.packageName.replace('.', '/'))
- packageDir.mkdirs()
-
- val sourceFile = File(packageDir, "$fileName.kt")
- sourceFile.writeText(source)
-
- val classesDir = File(workDir, "classes").apply { mkdirs() }
-
- LOG.debug("Compiling source: {}", sourceFile.absolutePath)
- LOG.info("Using {} project classpaths for compilation", context.compileClasspaths.size)
-
- val classpath = cachedClasspath
- ?: classpathManager.getCompilationClasspath(context.compileClasspaths).also {
- cachedClasspath = it
- }
-
- var compileResult: SourceCompileResult? = null
-
- if (compilerDaemon != null) {
- val daemonResult = try {
- compilerDaemon.compile(
- sourceFiles = listOf(sourceFile),
- outputDir = classesDir,
- classpath = classpath,
- composePlugin = classpathManager.getCompilerPlugin()
- )
- } catch (e: Exception) {
- LOG.warn("Daemon compilation failed, falling back to regular compiler", e)
- null
- }
-
- if (daemonResult != null) {
- if (daemonResult.success && !daemonInitialized) {
- daemonInitialized = true
- LOG.info("Daemon initialized successfully")
- }
- compileResult = SourceCompileResult(
- success = daemonResult.success,
- error = daemonResult.errorOutput.ifEmpty { daemonResult.output }
- )
- }
- }
-
- if (compileResult == null) {
- val result = compiler.compile(listOf(sourceFile), classesDir, context.compileClasspaths)
- compileResult = SourceCompileResult(
- success = result.success,
- error = result.errorOutput.ifEmpty {
- result.diagnostics
- .filter { it.severity == CompileDiagnostic.Severity.ERROR }
- .joinToString("\n") { it.message }
- },
- diagnostics = result.diagnostics
- )
- }
-
- if (!compileResult.success) {
- LOG.error("Compilation failed: {}", compileResult.error)
- throw CompilationException(
- message = compileResult.error.ifEmpty { "Compilation failed" },
- diagnostics = compileResult.diagnostics
- )
- }
-
- val dexDir = File(workDir, "dex").apply { mkdirs() }
-
- LOG.debug("Converting to DEX")
-
- var dexFile: File? = null
-
- if (compilerDaemon != null) {
- val daemonDex = try {
- compilerDaemon.dex(classesDir, dexDir)
- } catch (e: Exception) {
- LOG.warn("Daemon D8 failed, falling back to subprocess", e)
- null
- }
-
- if (daemonDex != null && daemonDex.success && daemonDex.dexFile != null) {
- dexFile = daemonDex.dexFile
- }
- }
-
- if (dexFile == null) {
- val dexResult = dexCompiler.compileToDex(classesDir, dexDir)
- if (!dexResult.success || dexResult.dexFile == null) {
- LOG.error("DEX compilation failed: {}", dexResult.errorMessage)
- throw CompilationException(
- message = dexResult.errorMessage.ifEmpty { "DEX compilation failed" }
- )
- }
- dexFile = dexResult.dexFile
- }
-
- try {
- cache.cacheDex(
- sourceHash,
- dexFile,
- fullClassName,
- parsedSource.previewConfigs.firstOrNull()?.functionName ?: ""
- )
- } catch (e: Exception) {
- LOG.warn("Failed to cache DEX file (non-fatal): {}", e.message)
- }
-
- LOG.info("Preview ready: {} with {} previews, {} project DEX files",
- fullClassName, parsedSource.previewConfigs.size, context.projectDexFiles.size)
-
- CompilationResult(
- dexFile = dexFile,
- className = fullClassName,
- runtimeDex = runtimeDex,
- projectDexFiles = context.projectDexFiles
- )
- }
- }
-
- override fun computeSourceHash(source: String): String {
- val cache = dexCache
- if (cache == null) {
- LOG.warn("DexCache not initialized, using non-deterministic hash fallback")
- return source.hashCode().toString()
- }
- return cache.computeSourceHash(source)
- }
-
- override fun reset() {
- compilerDaemon?.shutdown()
- classpathManager = null
- compiler = null
- compilerDaemon = null
- dexCompiler = null
- daemonInitialized = false
- cachedClasspath = null
- projectContext = null
- runtimeDex = null
- LOG.debug("Repository reset")
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/source/ProjectContextSource.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/source/ProjectContextSource.kt
deleted file mode 100644
index d4c4750f7a..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/source/ProjectContextSource.kt
+++ /dev/null
@@ -1,144 +0,0 @@
-package com.itsaky.androidide.compose.preview.data.source
-
-import com.itsaky.androidide.projects.IProjectManager
-import com.itsaky.androidide.projects.api.AndroidModule
-import org.json.JSONObject
-import org.slf4j.LoggerFactory
-import java.io.File
-import java.io.StringReader
-import java.util.Properties
-
-data class ProjectContext(
- val modulePath: String?,
- val variantName: String,
- val compileClasspaths: List,
- val intermediateClasspaths: Set,
- val projectDexFiles: List,
- val needsBuild: Boolean,
- val resourceApk: File? = null
-)
-
-class ProjectContextSource {
-
- fun resolveContext(filePath: String): ProjectContext {
- if (filePath.isBlank()) {
- LOG.info("Empty file path, returning default context")
- return ProjectContext(
- modulePath = null,
- variantName = "debug",
- compileClasspaths = emptyList(),
- intermediateClasspaths = emptySet(),
- projectDexFiles = emptyList(),
- needsBuild = false
- )
- }
-
- val file = File(filePath)
- LOG.info("Resolving project context for file: {}", file.absolutePath)
-
- val projectManager = IProjectManager.getInstance()
- val module = projectManager.findModuleForFile(file)
-
- if (module == null) {
- LOG.info("No module found for file")
- return ProjectContext(
- modulePath = null,
- variantName = "debug",
- compileClasspaths = emptyList(),
- intermediateClasspaths = emptySet(),
- projectDexFiles = emptyList(),
- needsBuild = false
- )
- }
-
- LOG.info("Found module: {} (type: {})", module.name, module.javaClass.simpleName)
-
- val intermediateClasspaths = module.getIntermediateClasspaths()
- val compileClasspaths = (module.getCompileClasspaths() + intermediateClasspaths).distinct()
-
- val projectDexFiles = module.getRuntimeDexFiles().toList()
- val androidModule = module as? AndroidModule
- val variantName = androidModule?.getSelectedVariant()?.name ?: "debug"
- val resourceApk = androidModule?.let { resolveResourceApk(it) }
- val needsBuild = intermediateClasspaths.isEmpty()
-
- LOG.info("Found {} total classpaths ({} compile, {} intermediate) for module: {}",
- compileClasspaths.size,
- compileClasspaths.size - intermediateClasspaths.size,
- intermediateClasspaths.size,
- module.name)
- LOG.info("Found {} project DEX files for runtime loading", projectDexFiles.size)
- LOG.info("Module path: {}, variant: {}, needsBuild: {}", module.path, variantName, needsBuild)
-
- if (!needsBuild) {
- intermediateClasspaths.forEach { cp ->
- LOG.info(" Intermediate: {} (exists: {})", cp.absolutePath, cp.exists())
- }
- projectDexFiles.forEach { dex ->
- LOG.info(" Project DEX: {} (exists: {})", dex.absolutePath, dex.exists())
- }
- }
-
- return ProjectContext(
- modulePath = module.path,
- variantName = variantName,
- compileClasspaths = compileClasspaths,
- intermediateClasspaths = intermediateClasspaths,
- projectDexFiles = projectDexFiles,
- needsBuild = needsBuild,
- resourceApk = resourceApk
- )
- }
-
- private fun resolveResourceApk(module: AndroidModule): File? {
- val variant = module.getSelectedVariant() ?: return null
- if (!variant.hasMainArtifact()) return null
- val artifact = variant.mainArtifact
- if (!artifact.hasAssembleTaskOutputListingFilePath()) return null
-
- val listing = resolveListingFile(File(artifact.assembleTaskOutputListingFilePath)) ?: return null
-
- return try {
- findApkInListing(listing)
- } catch (e: Exception) {
- LOG.error("Failed to parse APK output listing {}", listing.absolutePath, e)
- null
- }
- }
-
- private fun findApkInListing(listing: File): File? {
- val elements = JSONObject(listing.readText()).optJSONArray("elements") ?: return null
- for (i in 0 until elements.length()) {
- val outputFile = elements.optJSONObject(i)?.optString("outputFile").orEmpty()
- if (!outputFile.endsWith(".apk")) continue
-
- val candidate = File(listing.parentFile, outputFile)
- if (candidate.exists()) return candidate
- }
- LOG.warn("No APK entry found in output listing {}", listing.absolutePath)
- return null
- }
-
- private fun resolveListingFile(reference: File): File? {
- if (!reference.exists()) return null
-
- val text = reference.readText()
- if (text.trimStart().startsWith("{")) return reference
- if (!text.startsWith(REDIRECT_MARKER)) return null
-
- val target = Properties().apply { load(StringReader(text)) }
- .getProperty(REDIRECT_PROPERTY_NAME)
- ?.takeIf { it.isNotBlank() }
- ?: return null
-
- val file = File(target)
- val resolved = if (file.isAbsolute) file else File(reference.parentFile, target).normalize()
- return if (resolved.exists()) resolved else null
- }
-
- companion object {
- private val LOG = LoggerFactory.getLogger(ProjectContextSource::class.java)
- private const val REDIRECT_MARKER = "#- File Locator -"
- private const val REDIRECT_PROPERTY_NAME = "listingFile"
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/domain/PreviewSourceParser.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/domain/PreviewSourceParser.kt
deleted file mode 100644
index c5dbd9a3e2..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/domain/PreviewSourceParser.kt
+++ /dev/null
@@ -1,281 +0,0 @@
-package com.itsaky.androidide.compose.preview.domain
-
-import com.itsaky.androidide.compose.preview.PreviewConfig
-import com.itsaky.androidide.compose.preview.domain.model.ParsedPreviewSource
-import org.slf4j.LoggerFactory
-import kotlin.math.roundToInt
-
-class PreviewSourceParser {
-
- fun parse(source: String): ParsedPreviewSource? {
- val packageName = extractPackageName(source) ?: return null
- val className = extractClassName(source)
- val previewConfigs = detectAllPreviewFunctions(source, packageName)
- return ParsedPreviewSource(packageName, className, previewConfigs)
- }
-
- fun extractPackageName(source: String): String? {
- return PACKAGE_PATTERN.find(source)?.groupValues?.get(1)
- }
-
- fun extractClassName(source: String): String? {
- CLASS_PATTERN.find(source)?.groupValues?.get(1)?.let { return it }
- OBJECT_PATTERN.find(source)?.groupValues?.get(1)?.let { return it }
- return null
- }
-
- fun detectAllPreviewFunctions(source: String, packageName: String): List {
- val raws = mutableListOf()
- PREVIEW_OCCURRENCE.findAll(source).forEach { match ->
- val params = match.groupValues[1]
- val function = functionAfter(source, match.range.last + 1) ?: return@forEach
- val parameterProvider = extractPreviewParameter(function.params, source, packageName)
- raws.add(RawPreview(function.name, params, parameterProvider))
- }
-
- MULTIPREVIEW_OCCURRENCE.findAll(source).forEach { match ->
- val annotation = match.groupValues[1]
- val function = functionAfter(source, match.range.last + 1) ?: return@forEach
- val parameterProvider = extractPreviewParameter(function.params, source, packageName)
- multipreviewParams(annotation).forEach { synthesized ->
- raws.add(RawPreview(function.name, synthesized, parameterProvider))
- }
- }
-
- if (raws.isEmpty()) {
- COMPOSABLE_FUNCTION_PATTERN.findAll(source).forEach { match ->
- raws.add(RawPreview(match.groupValues[1], "", null))
- }
- }
-
- val countByFunction = raws.groupingBy { it.functionName }.eachCount()
- val ordinals = mutableMapOf()
-
- val configs = raws.map { raw ->
- val ordinal = ordinals.getOrDefault(raw.functionName, 0)
- ordinals[raw.functionName] = ordinal + 1
- val hasSiblings = (countByFunction[raw.functionName] ?: 1) > 1
- val name = extractStringParam(raw.params, "name")
-
- val displayName = when {
- name != null -> name
- hasSiblings -> "${raw.functionName} #${ordinal + 1}"
- else -> raw.functionName
- }
- val key = if (hasSiblings) "${raw.functionName}#$ordinal" else raw.functionName
-
- PreviewConfig(
- functionName = raw.functionName,
- key = key,
- displayName = displayName,
- group = extractStringParam(raw.params, "group"),
- widthDp = extractIntParam(raw.params, "widthDp"),
- heightDp = extractIntParam(raw.params, "heightDp"),
- showBackground = extractBooleanParam(raw.params, "showBackground"),
- backgroundColor = extractLongParam(raw.params, "backgroundColor"),
- uiMode = extractUiMode(raw.params),
- fontScale = extractFloatParam(raw.params, "fontScale"),
- locale = extractStringParam(raw.params, "locale"),
- parameterProvider = raw.parameterProvider?.providerFqn,
- parameterLimit = raw.parameterProvider?.limit ?: Int.MAX_VALUE,
- parameterIndex = raw.parameterProvider?.parameterIndex ?: 0
- )
- }
-
- LOG.debug("Detected {} preview functions: {}", configs.size, configs.map { it.functionName })
- return configs
- }
-
- private fun functionAfter(source: String, startIndex: Int): FunctionHeader? {
- val match = FUNCTION_AFTER.find(source, startIndex) ?: return null
- val params = extractParamList(source, match.range.last + 1)
- return FunctionHeader(match.groupValues[1], params)
- }
-
- private fun extractParamList(text: String, afterOpenParen: Int): String {
- var depth = 1
- var i = afterOpenParen
- while (i < text.length) {
- when (val c = text[i]) {
- '"', '\'' -> { i = skipLiteral(text, i, c); continue }
- '(' -> depth++
- ')' -> {
- depth--
- if (depth == 0) return text.substring(afterOpenParen, i)
- }
- }
- i++
- }
- return text.substring(afterOpenParen, i)
- }
-
- private fun skipLiteral(text: String, start: Int, quote: Char): Int {
- var i = start + 1
- while (i < text.length) {
- when (text[i]) {
- '\\' -> { i += 2; continue }
- quote -> return i + 1
- }
- i++
- }
- return i
- }
-
- private fun extractPreviewParameter(functionParams: String, source: String, packageName: String): ParameterProvider? {
- if (functionParams.isBlank()) return null
- val match = PREVIEW_PARAMETER.find(functionParams) ?: return null
- val content = match.groupValues[1]
- val providerRef = PROVIDER_CLASS.find(content)?.groupValues?.get(1) ?: return null
- val limit = LIMIT_PATTERN.find(content)?.groupValues?.get(1)?.toIntOrNull() ?: Int.MAX_VALUE
- val index = parameterIndexAt(functionParams, match.range.first)
- return ParameterProvider(resolveProviderFqn(providerRef, source, packageName), limit, index)
- }
-
- private fun parameterIndexAt(params: String, position: Int): Int {
- var depth = 0
- var commas = 0
- var i = 0
- while (i < position && i < params.length) {
- when (val c = params[i]) {
- '"', '\'' -> { i = skipLiteral(params, i, c); continue }
- '(', '[', '{', '<' -> depth++
- ')', ']', '}' -> if (depth > 0) depth--
- '>' -> if (depth > 0 && i > 0 && params[i - 1] != '-') depth--
- ',' -> if (depth == 0) commas++
- }
- i++
- }
- return commas
- }
-
- private fun resolveProviderFqn(reference: String, source: String, packageName: String): String {
- if (reference.contains('.')) return reference
- val simpleName = reference
- Regex("""^\s*import\s+([\w.]+\.$simpleName)\s*$""", RegexOption.MULTILINE)
- .find(source)?.groupValues?.get(1)?.let { return it }
- return "$packageName.$simpleName"
- }
-
- private fun multipreviewParams(annotation: String): List = when (annotation) {
- "PreviewLightDark" -> listOf(
- "name=\"Light\", uiMode=16",
- "name=\"Dark\", uiMode=32"
- )
- "PreviewFontScale" -> FONT_SCALES.map { scale ->
- "name=\"${(scale * 100).roundToInt()}%\", fontScale=${scale}f"
- }
- "PreviewScreenSizes" -> SCREEN_SIZES.map { (label, width, height) ->
- "name=\"$label\", widthDp=$width, heightDp=$height"
- }
- else -> emptyList()
- }
-
- private fun extractIntParam(params: String, name: String): Int? {
- if (params.isBlank()) return null
- return Regex("""\b$name\s*=\s*(\d+)""").find(params)?.groupValues?.get(1)?.toIntOrNull()
- }
-
- private fun extractStringParam(params: String, name: String): String? {
- if (params.isBlank()) return null
- return Regex("\\b$name\\s*=\\s*\"([^\"]*)\"").find(params)?.groupValues?.get(1)
- }
-
- private fun extractBooleanParam(params: String, name: String): Boolean {
- if (params.isBlank()) return false
- return Regex("""\b$name\s*=\s*(true|false)""").find(params)?.groupValues?.get(1) == "true"
- }
-
- private fun extractFloatParam(params: String, name: String): Float? {
- if (params.isBlank()) return null
- return Regex("""\b$name\s*=\s*([\d.]+)f?""").find(params)?.groupValues?.get(1)?.toFloatOrNull()
- }
-
- private fun extractUiMode(params: String): Int? {
- if (params.isBlank()) return null
- val raw = Regex("""\buiMode\s*=\s*([^,)]+)""").find(params)?.groupValues?.get(1)?.trim()
- ?: return null
-
- var result = 0
- var matched = false
- Regex("""0[xX][0-9a-fA-F]+|\d+""").findAll(raw).forEach { token ->
- val value = token.value
- val parsed = if (value.startsWith("0x", ignoreCase = true)) {
- value.substring(2).toIntOrNull(16)
- } else {
- value.toIntOrNull()
- }
- if (parsed != null) {
- result = result or parsed
- matched = true
- }
- }
- UI_MODE_CONSTANTS.forEach { (name, value) ->
- if (raw.contains(name)) {
- result = result or value
- matched = true
- }
- }
- return if (matched) result else null
- }
-
- private fun extractLongParam(params: String, name: String): Long? {
- if (params.isBlank()) return null
- val raw = Regex("""\b$name\s*=\s*(0[xX][0-9a-fA-F]+|\d+)""")
- .find(params)?.groupValues?.get(1) ?: return null
- return try {
- if (raw.startsWith("0x", ignoreCase = true)) raw.substring(2).toLong(16) else raw.toLong()
- } catch (e: NumberFormatException) {
- null
- }
- }
-
- private data class RawPreview(
- val functionName: String,
- val params: String,
- val parameterProvider: ParameterProvider?
- )
-
- private data class FunctionHeader(val name: String, val params: String)
-
- private data class ParameterProvider(val providerFqn: String, val limit: Int, val parameterIndex: Int)
-
- companion object {
-
- private val LOG = LoggerFactory.getLogger(PreviewSourceParser::class.java)
-
- private val PACKAGE_PATTERN = Regex("""^\s*package\s+([\w.]+)""", RegexOption.MULTILINE)
- private val CLASS_PATTERN = Regex("""^\s*class\s+(\w+)""", RegexOption.MULTILINE)
- private val OBJECT_PATTERN = Regex("""^\s*object\s+(\w+)""", RegexOption.MULTILINE)
-
- private val PREVIEW_OCCURRENCE = Regex("""@Preview\b\s*(?:\(([^)]*)\))?""")
-
- private val FUNCTION_AFTER = Regex("""fun\s+(\w+)\s*\(""")
-
- private val PREVIEW_PARAMETER = Regex("""@PreviewParameter\s*\(([^)]*)""")
- private val PROVIDER_CLASS = Regex("""([\w.]+)::class""")
- private val LIMIT_PATTERN = Regex("""\blimit\s*=\s*(\d+)""")
-
- private val MULTIPREVIEW_OCCURRENCE =
- Regex("""@(PreviewLightDark|PreviewFontScale|PreviewScreenSizes)\b""")
-
- private val FONT_SCALES = listOf(0.85f, 1.0f, 1.15f, 1.3f, 1.5f, 1.8f, 2.0f)
- private val SCREEN_SIZES = listOf(
- Triple("Phone", 411, 891),
- Triple("Foldable", 673, 841),
- Triple("Tablet", 1280, 800),
- Triple("Desktop", 1920, 1080)
- )
-
- private val UI_MODE_CONSTANTS = mapOf(
- "UI_MODE_NIGHT_YES" to 0x20,
- "UI_MODE_NIGHT_NO" to 0x10,
- "UI_MODE_TYPE_NORMAL" to 0x01,
- "UI_MODE_TYPE_DESK" to 0x02,
- "UI_MODE_TYPE_CAR" to 0x03,
- "UI_MODE_TYPE_TELEVISION" to 0x04,
- "UI_MODE_TYPE_WATCH" to 0x06
- )
-
- private val COMPOSABLE_FUNCTION_PATTERN = Regex("""@Composable\s+fun\s+(\w+)""")
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/domain/model/ParsedPreviewSource.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/domain/model/ParsedPreviewSource.kt
deleted file mode 100644
index 382db87f11..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/domain/model/ParsedPreviewSource.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.itsaky.androidide.compose.preview.domain.model
-
-import com.itsaky.androidide.compose.preview.PreviewConfig
-
-data class ParsedPreviewSource(
- val packageName: String,
- val className: String?,
- val previewConfigs: List
-)
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableInvoker.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableInvoker.kt
deleted file mode 100644
index 217d3c3ffe..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableInvoker.kt
+++ /dev/null
@@ -1,134 +0,0 @@
-package com.itsaky.androidide.compose.preview.runtime
-
-import androidx.compose.runtime.Composer
-import java.lang.reflect.InvocationTargetException
-import java.lang.reflect.Method
-import java.lang.reflect.Modifier as ReflectModifier
-import kotlin.math.ceil
-
-class PreviewSetupException(message: String, cause: Throwable? = null) : Exception(message, cause)
-
-object ComposableInvoker {
-
- fun findComposableMethod(clazz: Class<*>, functionName: String): Method? {
- val methods = clazz.declaredMethods
-
- methods.find { it.name == functionName }?.let {
- it.isAccessible = true
- return it
- }
-
- val candidates = methods.filter { method ->
- !method.name.contains("\$default") &&
- (method.name.startsWith("$functionName\$") || method.name == "${functionName}\$lambda")
- }
-
- return candidates.minByOrNull { it.parameterCount }?.also { it.isAccessible = true }
- }
-
- fun invokeSafely(
- clazz: Class<*>,
- method: Method,
- composer: Composer,
- parameterValue: Any? = null,
- parameterIndex: Int = 0
- ) {
- val isStatic = ReflectModifier.isStatic(method.modifiers)
-
- val instance = if (isStatic) {
- null
- } else {
- try {
- clazz.getDeclaredConstructor().newInstance()
- } catch (e: Exception) {
- throw PreviewSetupException("Failed to create instance for ${clazz.simpleName}", e)
- }
- }
-
- if (!isStatic && instance == null) {
- throw PreviewSetupException("Failed to create instance for ${clazz.simpleName}")
- }
-
- when (val signature = ComposeSignature.analyze(method)) {
- is ComposeSignature.NoArgs -> executeInvocation { method.invoke(instance) }
- is ComposeSignature.WithComposer -> invokeWithComposer(method, instance, signature, composer, parameterValue, parameterIndex)
- is ComposeSignature.Unsupported -> {
- throw PreviewSetupException("Unsupported signature: ${signature.reason}")
- }
- }
- }
-
- private fun invokeWithComposer(
- method: Method,
- instance: Any?,
- signature: ComposeSignature.WithComposer,
- composer: Composer,
- parameterValue: Any?,
- parameterIndex: Int
- ) {
- val args = arrayOfNulls(signature.totalParams)
- val realParamsCount = signature.composerIndex
-
- for (i in 0 until realParamsCount) {
- args[i] = getDefaultValue(signature.types[i])
- }
-
- val suppliesArg = parameterValue != null && parameterIndex in 0 until realParamsCount
- if (suppliesArg) {
- args[parameterIndex] = parameterValue
- }
-
- args[signature.composerIndex] = composer
-
- val changedInts = if (realParamsCount == 0) 1 else ceil(realParamsCount / COMPOSE_PARAMS_PER_CHANGED_INT).toInt()
- val changedStartIndex = signature.composerIndex + 1
- val changedEndIndex = minOf(changedStartIndex + changedInts, signature.totalParams)
-
- args.fill(COMPOSE_CHANGED_EVALUATE_ALL, fromIndex = changedStartIndex, toIndex = changedEndIndex)
- args.fill(COMPOSE_DEFAULT_USE_ALL_DEFAULTS, fromIndex = changedEndIndex, toIndex = signature.totalParams)
-
- if (suppliesArg) {
- clearDefaultBit(args, changedEndIndex, signature.totalParams, parameterIndex)
- }
-
- executeInvocation { method.invoke(instance, *args) }
- }
-
- private fun clearDefaultBit(args: Array, defaultStartIndex: Int, totalParams: Int, parameterIndex: Int) {
- val defaultIntIndex = defaultStartIndex + parameterIndex / COMPOSE_PARAMS_PER_DEFAULT_INT
- if (defaultIntIndex >= totalParams) return
- val bit = 1 shl (parameterIndex % COMPOSE_PARAMS_PER_DEFAULT_INT)
- val current = (args[defaultIntIndex] as? Int) ?: COMPOSE_DEFAULT_USE_ALL_DEFAULTS
- args[defaultIntIndex] = current and bit.inv()
- }
-
- private fun executeInvocation(action: () -> Unit) {
- try {
- action()
- } catch (e: InvocationTargetException) {
- throw e.targetException ?: e
- } catch (e: Exception) {
- throw PreviewSetupException("Reflection invocation failed", e)
- }
- }
-
- private fun getDefaultValue(type: Class<*>): Any? {
- if (!type.isPrimitive) return null
- return when (type) {
- Int::class.javaPrimitiveType -> 0
- Boolean::class.javaPrimitiveType -> false
- Float::class.javaPrimitiveType -> 0f
- Double::class.javaPrimitiveType -> 0.0
- Long::class.javaPrimitiveType -> 0L
- Byte::class.javaPrimitiveType -> 0.toByte()
- Short::class.javaPrimitiveType -> 0.toShort()
- Char::class.javaPrimitiveType -> '\u0000'
- else -> null
- }
- }
-
- private const val COMPOSE_PARAMS_PER_CHANGED_INT = 10.0
- private const val COMPOSE_PARAMS_PER_DEFAULT_INT = 32
- private const val COMPOSE_CHANGED_EVALUATE_ALL = 0
- private const val COMPOSE_DEFAULT_USE_ALL_DEFAULTS = -1
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableRenderer.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableRenderer.kt
deleted file mode 100644
index aacc0a7bc1..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableRenderer.kt
+++ /dev/null
@@ -1,172 +0,0 @@
-package com.itsaky.androidide.compose.preview.runtime
-
-import android.content.Context
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.SideEffect
-import androidx.compose.runtime.currentComposer
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.ComposeView
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.dp
-import org.slf4j.LoggerFactory
-import java.lang.reflect.Method
-
-class ComposableRenderer(
- private val composeView: ComposeView
-) {
-
- private var watchdog: Runnable? = null
-
- fun render(
- clazz: Class<*>,
- functionName: String,
- resourceContext: Context?,
- parameterValue: Any? = null,
- parameterIndex: Int = 0
- ) {
- cancelWatchdog()
-
- val composableMethod = ComposableInvoker.findComposableMethod(clazz, functionName)
- if (composableMethod == null) {
- showError("Composable function not found: $functionName")
- return
- }
-
- startWatchdog(functionName)
-
- try {
- composeView.setContent {
- val previewContext = resourceContext ?: LocalContext.current
- val previewConfiguration = previewContext.resources.configuration
- val previewDensity = Density(
- previewContext.resources.displayMetrics.density,
- previewConfiguration.fontScale
- )
- CompositionLocalProvider(
- LocalContext provides previewContext,
- LocalConfiguration provides previewConfiguration,
- LocalDensity provides previewDensity
- ) {
- MaterialTheme {
- Surface(color = MaterialTheme.colorScheme.background) {
- val setupError = remember { mutableStateOf(null) }
- val message = setupError.value
- if (message != null) {
- ErrorContent(message)
- } else {
- RenderComposable(clazz, composableMethod, parameterValue, parameterIndex) { cause ->
- LOG.error("render: setup failed fn={} - {}", functionName, describe(cause), cause)
- setupError.value = describe(cause)
- }
- }
- SideEffect { cancelWatchdog() }
- }
- }
- }
- }
- } catch (e: Throwable) {
- cancelWatchdog()
- val cause = (e as? PreviewSetupException)?.cause ?: e.cause ?: e
- LOG.error("render: FAILED fn={} - {}", functionName, describe(cause), e)
- showError(describe(cause))
- }
- }
-
- @Composable
- private fun RenderComposable(
- clazz: Class<*>,
- method: Method,
- parameterValue: Any?,
- parameterIndex: Int,
- onSetupError: (Throwable) -> Unit
- ) {
- val composer = currentComposer
- try {
- ComposableInvoker.invokeSafely(clazz, method, composer, parameterValue, parameterIndex)
- } catch (e: PreviewSetupException) {
- onSetupError(e)
- }
- }
-
- private fun startWatchdog(functionName: String) {
- val runnable = Runnable {
- watchdog = null
- if (!composeView.isAttachedToWindow) {
- return@Runnable
- }
- LOG.warn("Preview render timed out for {}", functionName)
- showError(
- "Preview timed out after $RENDER_TIMEOUT_MS ms.\n" +
- "Possible infinite loop or runaway recomposition in @$functionName."
- )
- }
- watchdog = runnable
- composeView.postDelayed(runnable, RENDER_TIMEOUT_MS)
- }
-
- private fun cancelWatchdog() {
- watchdog?.let { composeView.removeCallbacks(it) }
- watchdog = null
- }
-
- private fun showError(message: String) {
- cancelWatchdog()
- composeView.disposeComposition()
- composeView.setContent {
- MaterialTheme {
- ErrorContent(message)
- }
- }
- }
-
- @Composable
- private fun ErrorContent(message: String) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(Color(0xFFFFF3F3))
- .padding(16.dp),
- contentAlignment = Alignment.Center
- ) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Text(
- text = "Preview Error",
- style = MaterialTheme.typography.titleMedium,
- color = Color(0xFFB00020)
- )
- Text(
- text = message,
- style = MaterialTheme.typography.bodyMedium,
- color = Color(0xFF666666),
- modifier = Modifier.padding(top = 8.dp)
- )
- }
- }
- }
-
- private fun describe(throwable: Throwable): String {
- val type = throwable.javaClass.simpleName.ifEmpty { throwable.javaClass.name }
- return "$type: ${throwable.message ?: "no message"}"
- }
-
- companion object {
- private val LOG = LoggerFactory.getLogger(ComposableRenderer::class.java)
- private const val RENDER_TIMEOUT_MS = 10_000L
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposeClassLoader.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposeClassLoader.kt
deleted file mode 100644
index cbdb109841..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposeClassLoader.kt
+++ /dev/null
@@ -1,126 +0,0 @@
-package com.itsaky.androidide.compose.preview.runtime
-
-import android.content.Context
-import dalvik.system.DexClassLoader
-import org.slf4j.LoggerFactory
-import java.io.File
-
-class ComposeClassLoader(private val context: Context) {
-
- private var currentLoader: DexClassLoader? = null
- private var currentCacheKey: String? = null
- private var runtimeDexFile: File? = null
- private var projectDexFiles: List = emptyList()
-
- private val optimizedDir: File by lazy {
- File(context.codeCacheDir, OPTIMIZED_DIR_NAME).apply { mkdirs() }
- }
-
- fun setRuntimeDex(runtimeDex: File?) {
- if (runtimeDex?.absolutePath == runtimeDexFile?.absolutePath) {
- return
- }
- LOG.info("setRuntimeDex called: {} (current: {})",
- runtimeDex?.absolutePath ?: "null",
- runtimeDexFile?.absolutePath ?: "null")
- runtimeDexFile = runtimeDex
- release()
- LOG.info("Runtime DEX updated to: {}", runtimeDex?.absolutePath ?: "null")
- }
-
- fun setProjectDexFiles(dexFiles: List) {
- val existingFiles = dexFiles.filter { it.exists() }
- if (existingFiles.map { it.absolutePath } ==
- projectDexFiles.map { it.absolutePath }
- ) {
- return
- }
- LOG.info("setProjectDexFiles called: {} files ({} exist)",
- dexFiles.size, existingFiles.size)
- projectDexFiles = existingFiles
- release()
- existingFiles.forEach { LOG.info(" Project DEX: {}", it.absolutePath) }
- }
-
- fun loadClass(dexFile: File, className: String): Class<*>? {
- if (!dexFile.exists()) {
- LOG.error("DEX file not found: {}", dexFile.absolutePath)
- return null
- }
-
- return try {
- val loader = getOrCreateLoader(dexFile)
- loader.loadClass(className).also {
- LOG.debug("Loaded class: {}", className)
- }
- } catch (e: ClassNotFoundException) {
- LOG.error("Class not found: {}", className, e)
- null
- } catch (e: Exception) {
- LOG.error("Failed to load class: {}", className, e)
- null
- }
- }
-
- private fun getOrCreateLoader(dexFile: File): DexClassLoader {
- val runtimeDex = runtimeDexFile
-
- val dexFiles = mutableListOf()
- dexFiles.add(dexFile)
- dexFiles.addAll(projectDexFiles)
- if (runtimeDex != null && runtimeDex.exists()) {
- dexFiles.add(runtimeDex)
- }
-
- val cacheKey = buildCacheKey(dexFiles)
- val dexPath = dexFiles.joinToString(File.pathSeparator) { it.absolutePath }
-
- LOG.info("getOrCreateLoader: runtimeDex={}, projectDexFiles={}, totalDexFiles={}",
- runtimeDex?.absolutePath ?: "null",
- projectDexFiles.size,
- dexFiles.size)
-
- if (currentCacheKey == cacheKey && currentLoader != null) {
- LOG.debug("Reusing existing DexClassLoader")
- return currentLoader!!
- }
-
- currentLoader = null
- currentCacheKey = null
-
- optimizedDir.deleteRecursively()
- optimizedDir.mkdirs()
-
- val loader = DexClassLoader(
- dexPath,
- optimizedDir.absolutePath,
- null,
- context.classLoader
- )
-
- currentLoader = loader
- currentCacheKey = cacheKey
-
- LOG.info("Created new DexClassLoader with {} DEX files: {}",
- dexFiles.size, dexPath)
-
- return loader
- }
-
- private fun buildCacheKey(dexFiles: List): String {
- return dexFiles.joinToString("|") { file ->
- "${file.absolutePath}:${file.lastModified()}"
- }
- }
-
- fun release() {
- currentLoader = null
- currentCacheKey = null
- LOG.debug("Released ComposeClassLoader resources")
- }
-
- companion object {
- private val LOG = LoggerFactory.getLogger(ComposeClassLoader::class.java)
- private const val OPTIMIZED_DIR_NAME = "compose_preview_opt"
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposeSignature.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposeSignature.kt
deleted file mode 100644
index c154db7eba..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposeSignature.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.itsaky.androidide.compose.preview.runtime
-
-import java.lang.reflect.Method
-
-sealed class ComposeSignature {
- object NoArgs : ComposeSignature()
-
- class WithComposer(
- val composerIndex: Int,
- val totalParams: Int,
- val types: Array>
- ) : ComposeSignature()
-
- class Unsupported(val reason: String) : ComposeSignature()
-
- companion object {
- fun analyze(method: Method): ComposeSignature {
- val types = method.parameterTypes
- val paramCount = types.size
-
- if (paramCount == 0) return NoArgs
-
- val composerIndex = types.indexOfFirst { it.name == "androidx.compose.runtime.Composer" }
-
- if (composerIndex == -1) {
- return Unsupported("No Composer parameter found in ${method.name}")
- }
-
- for (i in (composerIndex + 1) until paramCount) {
- if (types[i] != Int::class.javaPrimitiveType && types[i] != Integer::class.java) {
- return Unsupported("Expected Int at index $i after Composer, but found ${types[i].simpleName}")
- }
- }
-
- return WithComposer(composerIndex, paramCount, types)
- }
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ProjectResourceContextFactory.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ProjectResourceContextFactory.kt
deleted file mode 100644
index 623116c6d5..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ProjectResourceContextFactory.kt
+++ /dev/null
@@ -1,86 +0,0 @@
-package com.itsaky.androidide.compose.preview.runtime
-
-import android.content.Context
-import android.content.ContextWrapper
-import android.content.res.AssetManager
-import android.content.res.Configuration
-import android.content.res.Resources
-import org.slf4j.LoggerFactory
-import java.io.File
-import java.lang.reflect.Method
-
-class ProjectResourceContextFactory(context: Context) {
-
- private val appContext = context.applicationContext
-
- private var cacheKey: String? = null
- private var cachedAssets: AssetManager? = null
- private val retainedAssets = mutableListOf()
-
- @Synchronized
- fun contextFor(apk: File?, configuration: Configuration): Context {
- val assets = assetsFor(apk)
- ?: return appContext.createConfigurationContext(configuration)
-
- @Suppress("DEPRECATION")
- val resources = Resources(assets, appContext.resources.displayMetrics, configuration)
- return object : ContextWrapper(appContext) {
- override fun getAssets(): AssetManager = assets
- override fun getResources(): Resources = resources
- }
- }
-
- private fun assetsFor(apk: File?): AssetManager? {
- if (apk == null || !apk.exists()) {
- LOG.warn("Project APK unavailable; resources fall back to IDE context: {}",
- apk?.absolutePath ?: "null")
- return null
- }
-
- val key = "${apk.absolutePath}:${apk.lastModified()}"
- if (key == cacheKey && cachedAssets != null) {
- return cachedAssets
- }
-
- val addAssetPath = ADD_ASSET_PATH ?: return null
-
- return try {
- @Suppress("DEPRECATION")
- val assets = AssetManager::class.java.getDeclaredConstructor().newInstance()
- val cookie = addAssetPath.invoke(assets, apk.absolutePath) as Int
- if (cookie == 0) {
- LOG.error("addAssetPath returned 0 for {}", apk.absolutePath)
- assets.close()
- return null
- }
- cachedAssets?.let { retainedAssets.add(it) }
- cachedAssets = assets
- cacheKey = key
- assets
- } catch (e: Throwable) {
- LOG.error("Failed to build project AssetManager from {}", apk.absolutePath, e)
- null
- }
- }
-
- @Synchronized
- fun release() {
- cachedAssets?.close()
- retainedAssets.forEach { it.close() }
- retainedAssets.clear()
- cachedAssets = null
- cacheKey = null
- }
-
- companion object {
- private val LOG = LoggerFactory.getLogger(ProjectResourceContextFactory::class.java)
- private val ADD_ASSET_PATH: Method? by lazy {
- try {
- AssetManager::class.java.getMethod("addAssetPath", String::class.java)
- } catch (e: Throwable) {
- LOG.error("addAssetPath reflective lookup failed; project resources unavailable", e)
- null
- }
- }
- }
-}
diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ui/BoundedComposeView.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ui/BoundedComposeView.kt
deleted file mode 100644
index 5800891ade..0000000000
--- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ui/BoundedComposeView.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package com.itsaky.androidide.compose.preview.ui
-
-import android.content.Context
-import android.util.AttributeSet
-import android.widget.FrameLayout
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.ComposeView
-import androidx.compose.ui.platform.ViewCompositionStrategy
-
-class BoundedComposeView @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
-) : FrameLayout(context, attrs, defStyleAttr) {
-
- val composeView: ComposeView = ComposeView(context).apply {
- layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
- }
-
- var maxHeightPx: Int = DEFAULT_MAX_HEIGHT_PX
- var explicitHeightPx: Int? = null
- var explicitWidthPx: Int? = null
-
- init {
- addView(composeView)
- }
-
- fun setViewCompositionStrategy(strategy: ViewCompositionStrategy) {
- composeView.setViewCompositionStrategy(strategy)
- }
-
- fun setContent(content: @Composable () -> Unit) {
- composeView.setContent(content)
- }
-
- override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
- val heightMode = MeasureSpec.getMode(heightMeasureSpec)
-
- val newWidthSpec = explicitWidthPx?.let {
- MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY)
- } ?: widthMeasureSpec
-
- val newHeightSpec = when {
- explicitHeightPx != null -> {
- MeasureSpec.makeMeasureSpec(explicitHeightPx!!, MeasureSpec.EXACTLY)
- }
- heightMode == MeasureSpec.UNSPECIFIED -> {
- MeasureSpec.makeMeasureSpec(maxHeightPx, MeasureSpec.AT_MOST)
- }
- else -> heightMeasureSpec
- }
-
- super.onMeasure(newWidthSpec, newHeightSpec)
- }
-
- companion object {
- private const val DEFAULT_MAX_HEIGHT_DP = 600
- private val DEFAULT_MAX_HEIGHT_PX = (DEFAULT_MAX_HEIGHT_DP *
- android.content.res.Resources.getSystem().displayMetrics.density).toInt()
- }
-}
diff --git a/compose-preview/src/main/res/drawable/ic_view_grid.xml b/compose-preview/src/main/res/drawable/ic_view_grid.xml
deleted file mode 100644
index 7ea321efa0..0000000000
--- a/compose-preview/src/main/res/drawable/ic_view_grid.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
diff --git a/compose-preview/src/main/res/drawable/ic_view_single.xml b/compose-preview/src/main/res/drawable/ic_view_single.xml
deleted file mode 100644
index 5612f7c0c9..0000000000
--- a/compose-preview/src/main/res/drawable/ic_view_single.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
diff --git a/compose-preview/src/main/res/layout/activity_compose_preview.xml b/compose-preview/src/main/res/layout/activity_compose_preview.xml
deleted file mode 100644
index 880fc1e6a1..0000000000
--- a/compose-preview/src/main/res/layout/activity_compose_preview.xml
+++ /dev/null
@@ -1,235 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/compose-preview/src/main/res/layout/fragment_compose_preview.xml b/compose-preview/src/main/res/layout/fragment_compose_preview.xml
deleted file mode 100644
index 21581b7b5e..0000000000
--- a/compose-preview/src/main/res/layout/fragment_compose_preview.xml
+++ /dev/null
@@ -1,111 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/compose-preview/src/main/res/layout/item_preview_card.xml b/compose-preview/src/main/res/layout/item_preview_card.xml
deleted file mode 100644
index 55dba0eb67..0000000000
--- a/compose-preview/src/main/res/layout/item_preview_card.xml
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/compose-preview/src/main/res/menu/menu_compose_preview.xml b/compose-preview/src/main/res/menu/menu_compose_preview.xml
deleted file mode 100644
index 98328b9997..0000000000
--- a/compose-preview/src/main/res/menu/menu_compose_preview.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/UIExtension.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/UIExtension.kt
index 65e6e1792e..e73371be97 100644
--- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/UIExtension.kt
+++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/UIExtension.kt
@@ -47,6 +47,20 @@ interface UIExtension : IPlugin {
* @return List of FAB actions for different screens
*/
fun getFabActions(): List = emptyList()
+
+ /**
+ * Provide the ids of editor-toolbar actions that should be hidden right now.
+ * Queried each time the toolbar is rebuilt (on file switch, edit, save, etc.), so
+ * the result may vary per file โ e.g. return the built-in XML preview action's id
+ * only while a Compose file is open. Determine the current file through your own
+ * editor service, the same way [ToolbarAction.isVisibleProvider] does. The host
+ * hides exactly the ids returned; there is no allow-list. Action ids are available
+ * as constants on [ToolbarActionIds]. Returning an id that is not currently on the
+ * toolbar is a no-op.
+ *
+ * @return Set of toolbar action ids to hide.
+ */
+ fun getHiddenToolbarActionIds(): Set = emptySet()
}
data class MenuItem @JvmOverloads constructor(
@@ -124,7 +138,28 @@ data class ToolbarAction(
val isVisible: Boolean = true,
val order: Int = 0,
val action: () -> Unit
-)
+) {
+ // NOTE: the provider callbacks below are intentionally body `var` properties rather than
+ // primary-constructor parameters. Adding constructor params would change the synthesized
+ // /copy()/componentN signatures and break ABI for plugins already compiled against the
+ // shipped plugin-api โ the exact pattern [MenuItem] uses for the same reason. The trade-off
+ // (copy()/equals() ignore these providers) is acceptable: plugins build ToolbarAction directly
+ // and the host never copies it. Do NOT move these into the constructor.
+
+ /**
+ * Optional callback to compute the enabled state dynamically at render time.
+ * When null, the static [isEnabled] is used. Mirrors the [MenuItem] providers.
+ */
+ var isEnabledProvider: (() -> Boolean)? = null
+
+ /**
+ * Optional callback to compute the visible state dynamically at render time.
+ * When null, the static [isVisible] is used. Unlike system toolbar actions
+ * (which only grey out when disabled), a plugin toolbar action is fully removed
+ * from the toolbar when this resolves to false.
+ */
+ var isVisibleProvider: (() -> Boolean)? = null
+}
enum class ShowAsAction {
ALWAYS,
diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt
index f7c02716c7..b63fe47494 100644
--- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt
+++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt
@@ -5,6 +5,25 @@ import android.app.Activity
import com.itsaky.androidide.plugins.extensions.IProject
import java.io.File
import java.io.InputStream
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Flattened, read-only snapshot of a module's build context for a given source file,
+ * returned by [IdeProjectService.getModuleContext]. All paths are absolute host paths.
+ *
+ * This is additive API: it carries the project-model data an on-device compiler/renderer
+ * needs (classpaths, runtime dex, selected variant, resource APK) without exposing any
+ * host-internal project types to the plugin.
+ */
+data class ModuleContext(
+ val modulePath: String?,
+ val variantName: String,
+ val compileClasspaths: List,
+ val intermediateClasspaths: List,
+ val runtimeDexFiles: List,
+ val resourceApk: File?,
+ val needsBuild: Boolean
+)
/**
* Service interface that provides access to Code On the Go project information.
@@ -30,6 +49,17 @@ interface IdeProjectService {
* @return The project at the given path, or null if not found
*/
fun getProjectByPath(path: File): IProject?
+
+ /**
+ * Resolves the build context (compile/intermediate classpaths, runtime dex files,
+ * selected variant, resource APK, and whether a build is needed) for the module that
+ * owns [filePath]. Returns null when no module can be resolved.
+ *
+ * Default returns null so this addition is binary-compatible: hosts that predate the
+ * method, and any implementor that does not override it, simply report "unavailable"
+ * (mirrors the default on [IdeUIService.openPluginScreen]).
+ */
+ fun getModuleContext(filePath: String): ModuleContext? = null
}
/**
@@ -200,6 +230,16 @@ interface IdeBuildService {
* @param callback The callback to unregister
*/
fun removeBuildStatusListener(callback: BuildStatusListener)
+
+ /**
+ * Executes the given Gradle task paths (e.g. ":app:assembleDebug") and completes with
+ * true on success, false on failure/cancellation.
+ *
+ * Default completes with false so this addition is binary-compatible: hosts that predate
+ * the method, and any implementor that does not override it, report "not executed".
+ */
+ fun executeTasks(vararg tasks: String): CompletableFuture =
+ CompletableFuture.completedFuture(false)
}
/**
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImpl.kt
index 3bedba5524..2816fc2ccc 100644
--- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImpl.kt
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeBuildServiceImpl.kt
@@ -3,6 +3,9 @@ package com.itsaky.androidide.plugins.manager.services
import com.itsaky.androidide.plugins.services.IdeBuildService
import com.itsaky.androidide.plugins.services.BuildStatusListener
+import com.itsaky.androidide.lookup.Lookup
+import com.itsaky.androidide.projects.builder.BuildService
+import java.util.concurrent.CompletableFuture
import java.util.concurrent.CopyOnWriteArraySet
/**
@@ -45,6 +48,19 @@ class IdeBuildServiceImpl private constructor() : IdeBuildService {
buildStatusListeners.remove(callback)
}
+ override fun executeTasks(vararg tasks: String): CompletableFuture {
+ val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)
+ ?: return CompletableFuture.completedFuture(false)
+
+ return try {
+ buildService.executeTasks(*tasks)
+ .thenApply { result -> result?.isSuccessful == true }
+ .exceptionally { false }
+ } catch (e: Exception) {
+ CompletableFuture.completedFuture(false)
+ }
+ }
+
/**
* Internal method to update build status (should be called by Code On the Go's build system)
*/
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt
index f9b83f019f..74402bbd97 100644
--- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt
@@ -5,6 +5,7 @@ package com.itsaky.androidide.plugins.manager.services
import com.itsaky.androidide.plugins.PluginPermission
import com.itsaky.androidide.plugins.extensions.IProject
import com.itsaky.androidide.plugins.services.IdeProjectService
+import com.itsaky.androidide.plugins.services.ModuleContext
import java.io.File
/**
@@ -80,6 +81,24 @@ class IdeProjectServiceImpl(
}
}
+ override fun getModuleContext(filePath: String): ModuleContext? {
+ if (!hasRequiredPermissions()) {
+ throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}")
+ }
+
+ val path = File(filePath)
+ if (!isPathAllowed(path)) {
+ throw SecurityException("Plugin $pluginId does not have access to path: ${path.absolutePath}")
+ }
+
+ return try {
+ ModuleContextResolver.resolve(filePath)
+ } catch (e: Exception) {
+
+ null
+ }
+ }
+
private fun hasRequiredPermissions(): Boolean {
return requiredPermissions.all { permission ->
permissions.contains(permission)
@@ -111,16 +130,21 @@ class IdeProjectServiceImpl(
}
return allowedPaths.any { allowedPath ->
- canonicalPath.startsWith(allowedPath)
+ canonicalPath == allowedPath || canonicalPath.startsWith(allowedPath + File.separator)
}
}
private fun getDefaultAllowedPaths(): List {
- return listOf(
- "/storage/emulated/0/AndroidIDEProjects",
- "/sdcard/AndroidIDEProjects",
- System.getProperty("user.home", "/") + "/AndroidIDEProjects",
- "/tmp/AndroidIDEProject" // Allow temporary project for demo purposes
- )
+ // Derive allowed roots from the official project API (IdeProjectService /
+ // ProjectProvider) rather than hardcoding a projects-directory name. CodeOnTheGo
+ // stores projects under CodeOnTheGoProjects and a project
+ // can live anywhere the user opened it, so the open project's rootDir is the source
+ // of truth for what this plugin may read.
+ val roots = mutableListOf()
+ runCatching {
+ projectProvider.getCurrentProject()?.rootDir?.let { roots.add(it) }
+ projectProvider.getAllProjects().forEach { roots.add(it.rootDir) }
+ }
+ return roots.mapNotNull { root -> runCatching { root.canonicalPath }.getOrNull() }
}
}
\ No newline at end of file
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/ModuleContextResolver.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/ModuleContextResolver.kt
new file mode 100644
index 0000000000..6c4cfd8ec6
--- /dev/null
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/ModuleContextResolver.kt
@@ -0,0 +1,87 @@
+package com.itsaky.androidide.plugins.manager.services
+
+import com.itsaky.androidide.plugins.services.ModuleContext
+import com.itsaky.androidide.projects.IProjectManager
+import com.itsaky.androidide.projects.api.AndroidModule
+import org.json.JSONObject
+import java.io.File
+import java.io.StringReader
+import java.util.Properties
+
+/**
+ * Host-side resolver that turns a source file path into a flattened [ModuleContext]
+ * for plugins.
+ */
+internal object ModuleContextResolver {
+
+ fun resolve(filePath: String): ModuleContext? {
+ if (filePath.isBlank()) return null
+
+ val file = File(filePath)
+ val module = IProjectManager.getInstance().findModuleForFile(file) ?: return null
+
+ val intermediateClasspaths = module.getIntermediateClasspaths()
+ val compileClasspaths = (module.getCompileClasspaths() + intermediateClasspaths).distinct()
+ val runtimeDexFiles = module.getRuntimeDexFiles().toList()
+ val androidModule = module as? AndroidModule
+ val variantName = androidModule?.getSelectedVariant()?.name ?: "debug"
+ val resourceApk = androidModule?.let { resolveResourceApk(it) }
+ val needsBuild = intermediateClasspaths.isEmpty()
+
+ return ModuleContext(
+ modulePath = module.path,
+ variantName = variantName,
+ compileClasspaths = compileClasspaths,
+ intermediateClasspaths = intermediateClasspaths.toList(),
+ runtimeDexFiles = runtimeDexFiles,
+ resourceApk = resourceApk,
+ needsBuild = needsBuild
+ )
+ }
+
+ private fun resolveResourceApk(module: AndroidModule): File? {
+ val variant = module.getSelectedVariant() ?: return null
+ if (!variant.hasMainArtifact()) return null
+ val artifact = variant.mainArtifact
+ if (!artifact.hasAssembleTaskOutputListingFilePath()) return null
+
+ val listing = resolveListingFile(File(artifact.assembleTaskOutputListingFilePath)) ?: return null
+ return try {
+ findApkInListing(listing)
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ private fun findApkInListing(listing: File): File? {
+ val elements = JSONObject(listing.readText()).optJSONArray("elements") ?: return null
+ for (i in 0 until elements.length()) {
+ val outputFile = elements.optJSONObject(i)?.optString("outputFile").orEmpty()
+ if (!outputFile.endsWith(".apk")) continue
+ val candidate = File(listing.parentFile, outputFile)
+ if (candidate.exists()) return candidate
+ }
+ return null
+ }
+
+ private fun resolveListingFile(reference: File): File? {
+ if (!reference.exists()) return null
+
+ val text = reference.readText()
+ if (text.trimStart().startsWith("{")) return reference
+ if (!text.startsWith(REDIRECT_MARKER)) return null
+
+ val target = Properties().apply { load(StringReader(text)) }
+ .getProperty(REDIRECT_PROPERTY_NAME)
+ ?.takeIf { it.isNotBlank() }
+ ?: return null
+
+ val resolved = File(target).let { f ->
+ if (f.isAbsolute) f else File(reference.parentFile, target).normalize()
+ }
+ return if (resolved.exists()) resolved else null
+ }
+
+ private const val REDIRECT_MARKER = "#- File Locator -"
+ private const val REDIRECT_PROPERTY_NAME = "listingFile"
+}
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginUiActionManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginUiActionManager.kt
new file mode 100644
index 0000000000..eb47155c14
--- /dev/null
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginUiActionManager.kt
@@ -0,0 +1,33 @@
+package com.itsaky.androidide.plugins.manager.ui
+
+import android.util.Log
+import com.itsaky.androidide.plugins.manager.core.PluginManager
+
+/**
+ * Collects the editor-toolbar action ids that enabled `UIExtension` plugins want hidden
+ * right now, by unioning each plugin's `getHiddenToolbarActionIds()`.
+ *
+ * Stateless: it reads live from [PluginManager.getEnabledUIExtensions], so a plugin
+ * disabled mid-session stops contributing on the next toolbar rebuild. There is no
+ * allow-list โ a plugin may request any toolbar action id; ids that are not currently
+ * on the toolbar are simply ignored by the caller. Each plugin call is isolated so one
+ * misbehaving plugin cannot break the toolbar.
+ */
+object PluginUiActionManager {
+
+ private const val TAG = "PluginUiActionManager"
+
+ fun getHiddenActionIds(): Set {
+ val extensions = PluginManager.getInstance()?.getEnabledUIExtensions() ?: return emptySet()
+
+ val hidden = mutableSetOf()
+ for (extension in extensions) {
+ runCatching { extension.getHiddenToolbarActionIds() }
+ .onSuccess { hidden.addAll(it) }
+ .onFailure { e ->
+ Log.w(TAG, "Failed to get hidden toolbar action ids from ${extension.javaClass.simpleName}", e)
+ }
+ }
+ return hidden
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1b75673afd..a4efd0a814 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -190,8 +190,7 @@ include(
":llama-api",
":llama-impl",
":llama-api",
- ":llama-impl",
- ":compose-preview"
+ ":llama-impl"
)
object FDroidConfig {