From e4fd087dbc009621c9b814aba933996e55d8117a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 16 Jun 2026 19:25:22 +0530 Subject: [PATCH 1/6] feat: add profiler module Signed-off-by: Akash Yadav --- profiler/.gitignore | 1 + profiler/build.gradle.kts | 17 +++++++++++++++++ profiler/consumer-rules.pro | 0 profiler/proguard-rules.pro | 21 +++++++++++++++++++++ profiler/src/main/AndroidManifest.xml | 4 ++++ settings.gradle.kts | 1 + 6 files changed, 44 insertions(+) create mode 100644 profiler/.gitignore create mode 100644 profiler/build.gradle.kts create mode 100644 profiler/consumer-rules.pro create mode 100644 profiler/proguard-rules.pro create mode 100644 profiler/src/main/AndroidManifest.xml diff --git a/profiler/.gitignore b/profiler/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/profiler/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/profiler/build.gradle.kts b/profiler/build.gradle.kts new file mode 100644 index 0000000000..5e4aeb930c --- /dev/null +++ b/profiler/build.gradle.kts @@ -0,0 +1,17 @@ +import com.itsaky.androidide.build.config.BuildConfig + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "${BuildConfig.PACKAGE_NAME}.profiler" +} + +dependencies { + api(projects.actions) + api(libs.androidx.fragment) + api(libs.androidx.lifecycle.viewmodel.ktx) + api(libs.androidx.lifecycle.runtime.ktx) +} \ No newline at end of file diff --git a/profiler/consumer-rules.pro b/profiler/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/profiler/proguard-rules.pro b/profiler/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/profiler/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/profiler/src/main/AndroidManifest.xml b/profiler/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a5918e68ab --- /dev/null +++ b/profiler/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 1b75673afd..f082d324de 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -150,6 +150,7 @@ include( ":lsp:jvm-symbol-models", ":lsp:kotlin", ":lsp:xml", + ":profiler", ":subprojects:aapt2-proto", ":subprojects:aaptcompiler", ":subprojects:builder-model-impl", From fd2ee2dc238420741675a395e2777a469482b29b Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sun, 28 Jun 2026 23:06:43 +0100 Subject: [PATCH 2/6] feat: extend plugin-api for compose-preview plugin extraction Add the host-side contract an external .cgp plugin needs to host the Jetpack Compose preview feature independently of the app: - IdeServices: add ModuleContext data class plus default-body IdeProjectService.getModuleContext(filePath) and IdeBuildService.executeTasks(vararg tasks) (binary-compatible additions via interface default methods) - ModuleContextResolver: resolve a module's compile/runtime classpaths, resource APK and build state via IProjectManager / AndroidModule - IdeProjectServiceImpl: implement getModuleContext over the resolver - IdeBuildServiceImpl: bridge executeTasks to the host BuildService --- .../plugins/services/IdeServices.kt | 40 +++++++++ .../manager/services/IdeBuildServiceImpl.kt | 16 ++++ .../manager/services/IdeProjectServiceImpl.kt | 14 +++ .../manager/services/ModuleContextResolver.kt | 87 +++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/ModuleContextResolver.kt 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..7b0680cdda 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,19 @@ class IdeProjectServiceImpl( } } + override fun getModuleContext(filePath: String): ModuleContext? { + if (!hasRequiredPermissions()) { + throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") + } + + return try { + ModuleContextResolver.resolve(filePath) + } catch (e: Exception) { + + null + } + } + private fun hasRequiredPermissions(): Boolean { return requiredPermissions.all { permission -> permissions.contains(permission) 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" +} From 60b767ecbbb802c0e9e7a2d0a6dc09548af061fa Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 29 Jun 2026 17:05:44 +0100 Subject: [PATCH 3/6] ADFA-3598: Move jetpack preview into a plugin --- .../itsaky/androidide/actions/ActionItem.kt | 9 + app/build.gradle.kts | 1 - .../actions/PluginToolbarActionItem.kt | 88 +++ .../actions/etc/PreviewLayoutAction.kt | 379 +++++------- .../editor/EditorHandlerActivity.kt | 19 +- .../androidide/utils/EditorActivityActions.kt | 6 + compose-preview/build.gradle.kts | 198 ------ compose-preview/src/main/AndroidManifest.xml | 11 - .../compose/preview/ComposePreviewActivity.kt | 566 ------------------ .../compose/preview/ComposePreviewFragment.kt | 199 ------ .../preview/ComposePreviewViewModel.kt | 340 ----------- .../preview/compiler/CompilerDaemon.kt | 448 -------------- .../compiler/ComposeClasspathManager.kt | 327 ---------- .../preview/compiler/ComposeCompiler.kt | 236 -------- .../preview/compiler/ComposeDexCompiler.kt | 150 ----- .../compose/preview/compiler/DexCache.kt | 98 --- .../repository/ComposePreviewRepository.kt | 47 -- .../ComposePreviewRepositoryImpl.kt | 276 --------- .../data/source/ProjectContextSource.kt | 144 ----- .../preview/domain/PreviewSourceParser.kt | 281 --------- .../domain/model/ParsedPreviewSource.kt | 9 - .../preview/runtime/ComposableInvoker.kt | 134 ----- .../preview/runtime/ComposableRenderer.kt | 172 ------ .../preview/runtime/ComposeClassLoader.kt | 126 ---- .../preview/runtime/ComposeSignature.kt | 38 -- .../runtime/ProjectResourceContextFactory.kt | 86 --- .../compose/preview/ui/BoundedComposeView.kt | 61 -- .../src/main/res/drawable/ic_view_grid.xml | 11 - .../src/main/res/drawable/ic_view_single.xml | 11 - .../res/layout/activity_compose_preview.xml | 235 -------- .../res/layout/fragment_compose_preview.xml | 111 ---- .../src/main/res/layout/item_preview_card.xml | 47 -- .../main/res/menu/menu_compose_preview.xml | 11 - .../plugins/extensions/UIExtension.kt | 30 +- .../manager/ui/PluginUiActionManager.kt | 33 + settings.gradle.kts | 3 +- 36 files changed, 346 insertions(+), 4595 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/actions/PluginToolbarActionItem.kt delete mode 100644 compose-preview/build.gradle.kts delete mode 100644 compose-preview/src/main/AndroidManifest.xml delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewActivity.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewFragment.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ComposePreviewViewModel.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/CompilerDaemon.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeClasspathManager.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeCompiler.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/ComposeDexCompiler.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/compiler/DexCache.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/repository/ComposePreviewRepository.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/repository/ComposePreviewRepositoryImpl.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/data/source/ProjectContextSource.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/domain/PreviewSourceParser.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/domain/model/ParsedPreviewSource.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableInvoker.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableRenderer.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposeClassLoader.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposeSignature.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ProjectResourceContextFactory.kt delete mode 100644 compose-preview/src/main/java/com/itsaky/androidide/compose/preview/ui/BoundedComposeView.kt delete mode 100644 compose-preview/src/main/res/drawable/ic_view_grid.xml delete mode 100644 compose-preview/src/main/res/drawable/ic_view_single.xml delete mode 100644 compose-preview/src/main/res/layout/activity_compose_preview.xml delete mode 100644 compose-preview/src/main/res/layout/fragment_compose_preview.xml delete mode 100644 compose-preview/src/main/res/layout/item_preview_card.xml delete mode 100644 compose-preview/src/main/res/menu/menu_compose_preview.xml create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginUiActionManager.kt 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..ebe2148cfd 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,166 @@ -/* - * 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: Throwable) { + 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.substringBefore("layout")) + 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 da9527798e..9c5aec8828 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 @@ -75,6 +75,7 @@ import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory import com.itsaky.androidide.plugins.manager.ui.PluginDrawableResolver import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager +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 @@ -447,15 +448,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) 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..4900b6fd8e 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,21 @@ data class ToolbarAction( val isVisible: Boolean = true, val order: Int = 0, val action: () -> Unit -) +) { + /** + * 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-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 f082d324de..96c60d5726 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -191,8 +191,7 @@ include( ":llama-api", ":llama-impl", ":llama-api", - ":llama-impl", - ":compose-preview" + ":llama-impl" ) object FDroidConfig { From 9e4799d826bda2c5c28f27d271fddf31ac556640 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 29 Jun 2026 17:12:57 +0100 Subject: [PATCH 4/6] Revert "feat: add profiler module" This reverts commit e4fd087dbc009621c9b814aba933996e55d8117a. --- profiler/.gitignore | 1 - profiler/build.gradle.kts | 17 ----------------- profiler/consumer-rules.pro | 0 profiler/proguard-rules.pro | 21 --------------------- profiler/src/main/AndroidManifest.xml | 4 ---- settings.gradle.kts | 1 - 6 files changed, 44 deletions(-) delete mode 100644 profiler/.gitignore delete mode 100644 profiler/build.gradle.kts delete mode 100644 profiler/consumer-rules.pro delete mode 100644 profiler/proguard-rules.pro delete mode 100644 profiler/src/main/AndroidManifest.xml diff --git a/profiler/.gitignore b/profiler/.gitignore deleted file mode 100644 index 42afabfd2a..0000000000 --- a/profiler/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/profiler/build.gradle.kts b/profiler/build.gradle.kts deleted file mode 100644 index 5e4aeb930c..0000000000 --- a/profiler/build.gradle.kts +++ /dev/null @@ -1,17 +0,0 @@ -import com.itsaky.androidide.build.config.BuildConfig - -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) -} - -android { - namespace = "${BuildConfig.PACKAGE_NAME}.profiler" -} - -dependencies { - api(projects.actions) - api(libs.androidx.fragment) - api(libs.androidx.lifecycle.viewmodel.ktx) - api(libs.androidx.lifecycle.runtime.ktx) -} \ No newline at end of file diff --git a/profiler/consumer-rules.pro b/profiler/consumer-rules.pro deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/profiler/proguard-rules.pro b/profiler/proguard-rules.pro deleted file mode 100644 index 481bb43481..0000000000 --- a/profiler/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/profiler/src/main/AndroidManifest.xml b/profiler/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e68ab..0000000000 --- a/profiler/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 96c60d5726..a4efd0a814 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -150,7 +150,6 @@ include( ":lsp:jvm-symbol-models", ":lsp:kotlin", ":lsp:xml", - ":profiler", ":subprojects:aapt2-proto", ":subprojects:aaptcompiler", ":subprojects:builder-model-impl", From f373d502f878017d06378244d743d5c857b416cf Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 29 Jun 2026 17:54:29 +0100 Subject: [PATCH 5/6] ADFA-3598: address CodeRabbit review feedback - PreviewLayoutAction: catch Exception (not Throwable) and log on resource-path parse failure; derive the layout base path from the last "layout/" segment instead of substringBefore("layout") - EditorHandlerActivity: rebuild EDITOR_TOOLBAR plugin actions on plugin disable so stale PluginToolbarActionItem callbacks are cleared - IdeProjectServiceImpl: path-validate getModuleContext via isPathAllowed() - ToolbarAction: document why the provider callbacks stay body vars (ABI compat) Deferred: permission-gating IdeBuildService.executeTasks needs a per-plugin permission-aware wrapper + a new build permission (build service is a shared singleton); to be done as a focused security change. --- .../itsaky/androidide/actions/etc/PreviewLayoutAction.kt | 5 +++-- .../androidide/activities/editor/EditorHandlerActivity.kt | 3 +++ .../itsaky/androidide/plugins/extensions/UIExtension.kt | 7 +++++++ .../plugins/manager/services/IdeProjectServiceImpl.kt | 5 +++++ 4 files changed, 18 insertions(+), 2 deletions(-) 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 ebe2148cfd..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 @@ -81,7 +81,8 @@ class PreviewLayoutAction(context: Context, override val order: Int) : EditorRel if (file != null && !viewModel.isInitializing && file.name.endsWith(".xml")) { val type = try { extractPathData(file).type - } catch (err: Throwable) { + } catch (err: Exception) { + LOG.warn("Failed to parse resource path for '{}'; hiding preview action", file.name, err) markInvisible() return } @@ -146,7 +147,7 @@ class PreviewLayoutAction(context: Context, override val order: Int) : EditorRel 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_FILE_PATH, file.absolutePath.substringBeforeLast("layout${File.separator}")) intent.putExtra(Constants.EXTRA_KEY_LAYOUT_FILE_NAME, file.name.substringBefore(".")) uiDesignerResultLauncher?.launch(intent) } 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 bee2def36c..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 @@ -92,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 @@ -1269,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/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 4900b6fd8e..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 @@ -139,6 +139,13 @@ data class ToolbarAction( 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. 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 7b0680cdda..e51fe6ebcc 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 @@ -86,6 +86,11 @@ class IdeProjectServiceImpl( 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) { From 9411f93493d129844aaae1acd9eb4ce22784304b Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Tue, 30 Jun 2026 10:49:46 +0100 Subject: [PATCH 6/6] fix regression from coderabbit comment --- .../manager/services/IdeProjectServiceImpl.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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 e51fe6ebcc..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 @@ -130,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