Render Jetpack Compose @Preview functions on-device, without building and running the whole app
+
Version 1.0.0 · Author: App Dev For All · Package: org.appdevforall.composepreview
+
+
+
+
+
+
+
1. Executive Overview
+
+ Compose Preview is a Code on the Go plugin that renders Jetpack Compose
+ @Preview functions directly on the device. Open a Kotlin file
+ that contains previews, tap the Compose preview action in the
+ editor toolbar, and the plugin draws your composables on a screen-sized
+ surface — no emulator and no full app launch.
+
+
+ It is a real, on-device compile-and-render, not a mock. The plugin compiles
+ the open file with the Kotlin and Compose compilers, converts the output to
+ DEX, loads it through a child class loader, and invokes the composable into a
+ live ComposeView. The Compose toolchain it needs is bundled with
+ the plugin, so the host IDE does not ship it.
+
+
+ The plugin implements three extension interfaces (IPlugin,
+ UIExtension, DocumentationExtension) and consumes
+ host services for the editor, project model, build system, UI, and
+ environment. It links only against the stable plugin-api
+ contract — never host-internal modules.
+
+
+
+
+
+
2. Core Functionality
+
+
The Compose preview action
+
+ The plugin contributes one editor-toolbar action via
+ UIExtension.getToolbarActions(). It is content-aware: it appears
+ only for a .kt file that contains @Preview, and it
+ sits in the preview slot. While such a file is open, the plugin also hides the
+ built-in XML layout preview action (getHiddenToolbarActionIds())
+ so there is a single, unambiguous preview entry point.
+
+
+
Preview modes
+
+
+
Mode
What it shows
+
+
+
All previews
Every @Preview in the file, each rendered as a labelled card in a scrolling list.
+
Single preview
One @Preview chosen from a selector, rendered on its own.
+
+
+
+
Per-preview options honoured
+
+ Each preview's annotation parameters are applied when it renders:
+
+
+
+
Parameter
Effect
+
+
+
showBackground
Draws the default surface behind the composable.
+
uiMode
Light or dark configuration (see below).
+
fontScale
Scales text to preview accessibility sizes.
+
widthDp / heightDp
Constrains the render surface.
+
@PreviewParameter
Each value the provider supplies renders as its own card.
+
+
+
+
Light and dark
+
+ A preview renders light by default and switches to dark only
+ when its annotation requests it — independent of the IDE's own theme, the
+ way Android Studio renders previews. The plugin seeds each render's
+ Configuration with UI_MODE_NIGHT_NO and overrides the
+ night bits only when uiMode asks for them.
+
+ Previews resolve their symbols against the module's compiled output. When that
+ output is missing, the panel shows a Build Required screen with
+ a Build Project button; tapping it runs the module's assemble
+ task through the host build service, after which the preview becomes available.
+ Edits to the file being previewed are recompiled on the fly — no full
+ rebuild for in-file changes.
+
Entry point. Contributes the toolbar action, the XML-preview hide rule, the long-press tooltip, and the Tier 3 docs.
IPlugin, UIExtension, DocumentationExtension
+
PreviewSourceParser
Parses the source for @Preview functions, their parameters, and @PreviewParameter providers.
None
+
ComposeCompiler / CompilerDaemon
Runs the Kotlin + Compose compiler on-device, then D8 to DEX.
None
+
ComposableRenderer / ComposableInvoker
Loads the compiled preview and invokes the composable into a ComposeView under a controlled, always-resumed lifecycle.
None
+
ComposePreviewViewModel
Drives the preview state machine (Initializing · Compiling · Building · NeedsBuild · Ready · Error).
extends ViewModel
+
+
+
+
Render pipeline
+
+ open .kt with @Preview + tap the Compose preview action
+ |
+ v
+ PreviewSourceParser ── find @Preview fns, params, @PreviewParameter
+ |
+ v
+ ComposeCompiler (Kotlin + Compose plugin) ──> D8 ──> DEX
+ | classpath from IdeProjectService.getModuleContext
+ | + bundled assets/compose/compose-jars.zip
+ v
+ child DexClassLoader (parented to the plugin loader)
+ |
+ v
+ ComposableInvoker ──> invoke @Preview into a live ComposeView
+ |
+ v
+ rendered preview card(s) (all-mode list / single-mode view)
+
+ Why a bundled toolchain. The on-device Kotlin/Compose
+ compiler and the Compose runtime jars ship inside the plugin's
+ assets/compose/compose-jars.zip. Keeping them in the plugin means
+ the host IDE no longer carries Compose just for previews.
+
+
+
Build configuration
+
+
Setting
Value
+
Gradle plugin
com.itsaky.androidide.plugins.build
+
Compile / Target SDK
34
+
Min SDK
26
+
Java / Kotlin target
17
+
Compose
Enabled; bundled (compose-bom) so the host need not ship it
+
Plugin API
compileOnly via ../libs/plugin-api.jar
+
Output format
.cgp package
+
+
+
+
+
+
4. Integration Points
+
+ Compose Preview implements three extension interfaces and consumes five host
+ services, all through plugin-api.
+
+
+
4.1 Plugin lifecycle (IPlugin)
+
+ initialize() stores the PluginContext and sets up the
+ environment; activate() / deactivate() /
+ dispose() round out the lifecycle. The preview screen is opened
+ full-screen through IdeUIService.openPluginScreen().
+
+
+
4.2 Toolbar action and visibility (UIExtension)
+
+ getToolbarActions() contributes the Compose preview action with an
+ isVisibleProvider that shows it only for Compose files.
+ getHiddenToolbarActionIds() returns the built-in XML preview
+ action's id while a Compose file is open, so only one preview action shows.
+
+
+
4.3 Host services consumed
+
+
+
Service
Used for
+
+
+
IdeEditorService
Read the current file and its contents to detect @Preview.
+
IdeProjectService
getModuleContext(): module classpath, variant, and build-output locations for compilation.
+
IdeBuildService
executeTasks(): run the module's assemble task for the Build Required flow.
+
IdeUIService
openPluginScreen(): present the preview full-screen.
+
IdeEnvironmentService
Android SDK location and the plugin's private data directory.
+
+
+
+
4.4 Documentation (DocumentationExtension)
+
+ The long-press tooltip on the toolbar action comes from
+ getTooltipEntries() (summary + detail), and this page is the
+ Tier 3 bundle declared by getTier3DocsAssetPath() = "docs".
+ Files under assets/docs/ are indexed at install time and served
+ from http://localhost:6174/plugin/<pluginId>/<file>.
+
+
+
4.5 Class loading and theme recreation
+
+ Previews compile to a child DexClassLoader parented to the
+ plugin's own loader, so the single bundled Compose runtime stays
+ classloader-consistent across the nested load. Render surfaces are created with
+ the host activity context (not the plugin context) so composables that touch
+ the window or theme behave, and the preview survives a uiMode (theme) change.
+
+ Filesystem and project-structure access back source reading and module
+ resolution; native.code covers the bundled on-device toolchain.
+
+
+
+
+
+
5. Deployment & Usage
+
+
Building
+
cd compose-preview
+./gradlew clean assemblePluginDebug # or assemblePlugin for release
+
+ Produces compose-preview/build/plugin/compose-preview-debug.cgp,
+ the bundle you sideload into Code on the Go.
+
+
+ Always clean first. The plugin builder copies the
+ built APK to the .cgp and then deletes the source APK, so an
+ incremental build can pick up an empty artifact. A correct build is tens of MB
+ (it carries the bundled Compose toolchain).
+
+
+
Installation
+
+
Open Preferences → Plugin Manager → +.
+
Select the compose-preview-debug.cgp file.
+
The IDE discovers ComposePreviewPlugin via manifest metadata and activates it.
+
+
+
Using the plugin
+
+
Open a .kt file with at least one @Preview in a Compose module.
+
Tap the Compose preview action in the editor toolbar.
+
If prompted with Build Required, tap Build Project once; then the preview renders.
+
Switch between all previews and a single preview from the toolbar / selector.
+
Long-press the action for a quick tooltip; this page is the full reference.
+
+
+
Runtime requirements
+
+
Requirement
Value
+
Min Android version
API 26 (Android 8)
+
Min IDE version
1.0.0
+
Permissions
filesystem.read, project.structure, native.code
+
Network access
None
+
+
+
+
+
+
6. Key Benefits
+
+
Real previews, on device. Composables are compiled and run, not approximated, so what you see is what the framework draws.
+
No full app run. Iterate on UI without launching the app on a device or emulator.
+
One clear preview entry point. The action shows only for Compose files and hides the XML preview while active.
+
Faithful light / dark. Previews default to light and honour uiMode per annotation, independent of the IDE theme.
+
Self-contained toolchain. The Compose compiler and runtime ship in the plugin, keeping them out of the host IDE.
+
Plugin-API only. A reference for a compiler/render plugin built entirely on the stable plugin-api contract.
+
+
+
+
+
+
7. Attribution & License
+
+ Compose Preview is an open-source example plugin for Code on the Go. Its source
+ is licensed per the surrounding plugin-examples repository (see
+ LICENSE at the repo root).
+
+
+
Jetpack Compose and the Kotlin compiler are used under their respective
+ Apache License 2.0 terms; their trademarks and logos are not used.
+
The plugin makes no network calls and transmits no data; compilation and
+ rendering happen entirely on-device.
Render Jetpack Compose @Preview functions on-device, without building and running the whole app
+
Version 1.0.0 · Author: App Dev For All · Package: org.appdevforall.composepreview
+
+
+
+
+
+
+
1. Executive Overview
+
+ Compose Preview is a Code on the Go plugin that renders Jetpack Compose
+ @Preview functions directly on the device. Open a Kotlin file
+ that contains previews, tap the Compose preview action in the
+ editor toolbar, and the plugin draws your composables on a screen-sized
+ surface — no emulator and no full app launch.
+
+
+ It is a real, on-device compile-and-render, not a mock. The plugin compiles
+ the open file with the Kotlin and Compose compilers, converts the output to
+ DEX, loads it through a child class loader, and invokes the composable into a
+ live ComposeView. The Compose toolchain it needs is bundled with
+ the plugin, so the host IDE does not ship it.
+
+
+ The plugin implements three extension interfaces (IPlugin,
+ UIExtension, DocumentationExtension) and consumes
+ host services for the editor, project model, build system, UI, and
+ environment. It links only against the stable plugin-api
+ contract — never host-internal modules.
+
+
+
+
+
+
2. Core Functionality
+
+
The Compose preview action
+
+ The plugin contributes one editor-toolbar action via
+ UIExtension.getToolbarActions(). It is content-aware: it appears
+ only for a .kt file that contains @Preview, and it
+ sits in the preview slot. While such a file is open, the plugin also hides the
+ built-in XML layout preview action (getHiddenToolbarActionIds())
+ so there is a single, unambiguous preview entry point.
+
+
+
Preview modes
+
+
+
Mode
What it shows
+
+
+
All previews
Every @Preview in the file, each rendered as a labelled card in a scrolling list.
+
Single preview
One @Preview chosen from a selector, rendered on its own.
+
+
+
+
Per-preview options honoured
+
+ Each preview's annotation parameters are applied when it renders:
+
+
+
+
Parameter
Effect
+
+
+
showBackground
Draws the default surface behind the composable.
+
uiMode
Light or dark configuration (see below).
+
fontScale
Scales text to preview accessibility sizes.
+
widthDp / heightDp
Constrains the render surface.
+
@PreviewParameter
Each value the provider supplies renders as its own card.
+
+
+
+
Light and dark
+
+ A preview renders light by default and switches to dark only
+ when its annotation requests it — independent of the IDE's own theme, the
+ way Android Studio renders previews. The plugin seeds each render's
+ Configuration with UI_MODE_NIGHT_NO and overrides the
+ night bits only when uiMode asks for them.
+
+ Previews resolve their symbols against the module's compiled output. When that
+ output is missing, the panel shows a Build Required screen with
+ a Build Project button; tapping it runs the module's assemble
+ task through the host build service, after which the preview becomes available.
+ Edits to the file being previewed are recompiled on the fly — no full
+ rebuild for in-file changes.
+
Entry point. Contributes the toolbar action, the XML-preview hide rule, the long-press tooltip, and the Tier 3 docs.
IPlugin, UIExtension, DocumentationExtension
+
PreviewSourceParser
Parses the source for @Preview functions, their parameters, and @PreviewParameter providers.
None
+
ComposeCompiler / CompilerDaemon
Runs the Kotlin + Compose compiler on-device, then D8 to DEX.
None
+
ComposableRenderer / ComposableInvoker
Loads the compiled preview and invokes the composable into a ComposeView under a controlled, always-resumed lifecycle.
None
+
ComposePreviewViewModel
Drives the preview state machine (Initializing · Compiling · Building · NeedsBuild · Ready · Error).
extends ViewModel
+
+
+
+
Render pipeline
+
+ open .kt with @Preview + tap the Compose preview action
+ |
+ v
+ PreviewSourceParser ── find @Preview fns, params, @PreviewParameter
+ |
+ v
+ ComposeCompiler (Kotlin + Compose plugin) ──> D8 ──> DEX
+ | classpath from IdeProjectService.getModuleContext
+ | + bundled assets/compose/compose-jars.zip
+ v
+ child DexClassLoader (parented to the plugin loader)
+ |
+ v
+ ComposableInvoker ──> invoke @Preview into a live ComposeView
+ |
+ v
+ rendered preview card(s) (all-mode list / single-mode view)
+
+ Why a bundled toolchain. The on-device Kotlin/Compose
+ compiler and the Compose runtime jars ship inside the plugin's
+ assets/compose/compose-jars.zip. Keeping them in the plugin means
+ the host IDE no longer carries Compose just for previews.
+
+
+
Build configuration
+
+
Setting
Value
+
Gradle plugin
com.itsaky.androidide.plugins.build
+
Compile / Target SDK
34
+
Min SDK
26
+
Java / Kotlin target
17
+
Compose
Enabled; bundled (compose-bom) so the host need not ship it
+
Plugin API
compileOnly via ../libs/plugin-api.jar
+
Output format
.cgp package
+
+
+
+
+
+
4. Integration Points
+
+ Compose Preview implements three extension interfaces and consumes five host
+ services, all through plugin-api.
+
+
+
4.1 Plugin lifecycle (IPlugin)
+
+ initialize() stores the PluginContext and sets up the
+ environment; activate() / deactivate() /
+ dispose() round out the lifecycle. The preview screen is opened
+ full-screen through IdeUIService.openPluginScreen().
+
+
+
4.2 Toolbar action and visibility (UIExtension)
+
+ getToolbarActions() contributes the Compose preview action with an
+ isVisibleProvider that shows it only for Compose files.
+ getHiddenToolbarActionIds() returns the built-in XML preview
+ action's id while a Compose file is open, so only one preview action shows.
+
+
+
4.3 Host services consumed
+
+
+
Service
Used for
+
+
+
IdeEditorService
Read the current file and its contents to detect @Preview.
+
IdeProjectService
getModuleContext(): module classpath, variant, and build-output locations for compilation.
+
IdeBuildService
executeTasks(): run the module's assemble task for the Build Required flow.
+
IdeUIService
openPluginScreen(): present the preview full-screen.
+
IdeEnvironmentService
Android SDK location and the plugin's private data directory.
+
+
+
+
4.4 Documentation (DocumentationExtension)
+
+ The long-press tooltip on the toolbar action comes from
+ getTooltipEntries() (summary + detail), and this page is the
+ Tier 3 bundle declared by getTier3DocsAssetPath() = "docs".
+ Files under assets/docs/ are indexed at install time and served
+ from http://localhost:6174/plugin/<pluginId>/<file>.
+
+
+
4.5 Class loading and theme recreation
+
+ Previews compile to a child DexClassLoader parented to the
+ plugin's own loader, so the single bundled Compose runtime stays
+ classloader-consistent across the nested load. Render surfaces are created with
+ the host activity context (not the plugin context) so composables that touch
+ the window or theme behave, and the preview survives a uiMode (theme) change.
+
+ Filesystem and project-structure access back source reading and module
+ resolution; native.code covers the bundled on-device toolchain.
+
+
+
+
+
+
5. Deployment & Usage
+
+
Building
+
cd compose-preview
+./gradlew clean assemblePluginDebug # or assemblePlugin for release
+
+ Produces compose-preview/build/plugin/compose-preview-debug.cgp,
+ the bundle you sideload into Code on the Go.
+
+
+ Always clean first. The plugin builder copies the
+ built APK to the .cgp and then deletes the source APK, so an
+ incremental build can pick up an empty artifact. A correct build is tens of MB
+ (it carries the bundled Compose toolchain).
+
+
+
Installation
+
+
Open Preferences → Plugin Manager → +.
+
Select the compose-preview-debug.cgp file.
+
The IDE discovers ComposePreviewPlugin via manifest metadata and activates it.
+
+
+
Using the plugin
+
+
Open a .kt file with at least one @Preview in a Compose module.
+
Tap the Compose preview action in the editor toolbar.
+
If prompted with Build Required, tap Build Project once; then the preview renders.
+
Switch between all previews and a single preview from the toolbar / selector.
+
Long-press the action for a quick tooltip; this page is the full reference.
+
+
+
Runtime requirements
+
+
Requirement
Value
+
Min Android version
API 26 (Android 8)
+
Min IDE version
1.0.0
+
Permissions
filesystem.read, project.structure, native.code
+
Network access
None
+
+
+
+
+
+
6. Key Benefits
+
+
Real previews, on device. Composables are compiled and run, not approximated, so what you see is what the framework draws.
+
No full app run. Iterate on UI without launching the app on a device or emulator.
+
One clear preview entry point. The action shows only for Compose files and hides the XML preview while active.
+
Faithful light / dark. Previews default to light and honour uiMode per annotation, independent of the IDE theme.
+
Self-contained toolchain. The Compose compiler and runtime ship in the plugin, keeping them out of the host IDE.
+
Plugin-API only. A reference for a compiler/render plugin built entirely on the stable plugin-api contract.
+
+
+
+
+
+
7. Attribution & License
+
+ Compose Preview is an open-source example plugin for Code on the Go. Its source
+ is licensed per the surrounding plugin-examples repository (see
+ LICENSE at the repo root).
+
+
+
Jetpack Compose and the Kotlin compiler are used under their respective
+ Apache License 2.0 terms; their trademarks and logos are not used.
+
The plugin makes no network calls and transmits no data; compilation and
+ rendering happen entirely on-device.
+
+
+
+
+
+
+
diff --git a/compose-preview/src/main/assets/icon_day.png b/compose-preview/src/main/assets/icon_day.png
new file mode 100644
index 00000000..75dd4188
Binary files /dev/null and b/compose-preview/src/main/assets/icon_day.png differ
diff --git a/compose-preview/src/main/assets/icon_night.png b/compose-preview/src/main/assets/icon_night.png
new file mode 100644
index 00000000..d912476d
Binary files /dev/null and b/compose-preview/src/main/assets/icon_night.png differ
diff --git a/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewFragment.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewFragment.kt
new file mode 100644
index 00000000..f530ec7d
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewFragment.kt
@@ -0,0 +1,576 @@
+package org.appdevforall.composepreview
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.res.Configuration
+import android.graphics.Color
+import android.os.Bundle
+import android.widget.Toast
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+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.plugins.base.PluginFragmentHelper
+import com.itsaky.androidide.plugins.services.IdeBuildService
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.appdevforall.composepreview.databinding.FragmentComposePreviewBinding
+import org.appdevforall.composepreview.runtime.ComposableRenderer
+import org.appdevforall.composepreview.runtime.ComposeClassLoader
+import org.appdevforall.composepreview.runtime.ProjectResourceContextFactory
+import org.appdevforall.composepreview.ui.BoundedComposeView
+import org.appdevforall.composepreview.R
+import org.appdevforall.composepreview.R as ResourcesR
+import org.slf4j.LoggerFactory
+import java.io.File
+import java.util.Locale
+
+/**
+ * Full-screen Compose preview, hosted by the IDE's PluginScreenActivity. Ports the original
+ * in-IDE ComposePreviewActivity: multi-preview (ALL mode) with labelled cards, a SINGLE-mode
+ * selector, an ALL/SINGLE toggle, @PreviewParameter expansion, and per-@Preview background/size.
+ *
+ * Render views are created with the host Activity context so previewed app code that does
+ * `(view.context as Activity).window` (the standard Compose theme template) works. The
+ * composables' own resources come from the project via LocalContext (ProjectResourceContextFactory).
+ */
+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 singlePreviewView: ComposeView? = null
+ private var singleRenderer: ComposableRenderer? = null
+ private val multiRenderers = mutableMapOf()
+
+ private var resourceContextFactory: ProjectResourceContextFactory? = null
+ private var loadedClass: Class<*>? = null
+ private var loadJob: Job? = null
+ 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 var buildTriggered = false
+ private var lastErrorText: String = ""
+
+ private var sourceCode: String = DEFAULT_SOURCE
+
+ private val pluginContext: Context
+ get() = PluginFragmentHelper.getPluginContext(ComposePreviewPlugin.PLUGIN_ID) ?: requireContext()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val pluginInflater = PluginFragmentHelper.getPluginInflater(ComposePreviewPlugin.PLUGIN_ID, inflater)
+ _binding = FragmentComposePreviewBinding.inflate(pluginInflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ resourceContextFactory = ProjectResourceContextFactory(requireActivity())
+ classLoader = ComposeClassLoader(pluginContext)
+
+ setupToolbar()
+ setupPreviewSelector()
+ setupSinglePreview()
+ setupBuildButtons()
+ observeState()
+
+ val filePath = ComposePreviewState.filePath ?: ""
+ ComposePreviewState.sourceCode?.let { sourceCode = it }
+ viewModel.initialize(pluginContext, filePath, sourceCode)
+ }
+
+ private fun setupToolbar() {
+ binding.toolbar.title = (ComposePreviewState.filePath ?: "").substringAfterLast('/')
+ .ifEmpty { getString(ResourcesR.string.title_compose_preview) }
+ // Close (X): set programmatically — app:navigationIcon in the layout isn't applied when
+ // the toolbar is inflated in the plugin context.
+ binding.toolbar.setNavigationIcon(R.drawable.ic_close)
+ binding.toolbar.navigationContentDescription = "Close preview"
+ binding.toolbar.setNavigationOnClickListener { requireActivity().finish() }
+
+ if (binding.toolbar.menu.size() == 0) {
+ binding.toolbar.inflateMenu(R.menu.menu_compose_preview)
+ }
+ toggleMenuItem = binding.toolbar.menu.findItem(R.id.action_toggle_mode)?.apply {
+ // Force the toolbar icon: app:showAsAction in the menu XML is ignored when the
+ // menu is inflated in the plugin context, so it would otherwise fall to overflow.
+ // Always in-bar AND with a text label, so the mode switch is self-explanatory
+ // instead of a cryptic icon. The title is updated per mode in updateDisplayMode.
+ setShowAsAction(
+ android.view.MenuItem.SHOW_AS_ACTION_ALWAYS or android.view.MenuItem.SHOW_AS_ACTION_WITH_TEXT
+ )
+ setIcon(R.drawable.ic_view_single)
+ title = "Single"
+ // Per-item listener: the toolbar-level OnMenuItemClickListener does not fire for
+ // this in-bar action item in the plugin-hosted toolbar; a per-item listener does.
+ setOnMenuItemClickListener {
+ viewModel.toggleDisplayMode()
+ true
+ }
+ }
+ }
+
+ private fun setupPreviewSelector() {
+ selectorAdapter = ArrayAdapter(
+ requireActivity(),
+ 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() {
+ // Swap the layout-inflated ComposeView (plugin context) for one backed by the host
+ // Activity, so the `view.context as Activity` window cast in the previewed theme works.
+ val activityView = ComposeView(requireActivity())
+ val container = binding.singlePreviewView.parent as ViewGroup
+ val index = container.indexOfChild(binding.singlePreviewView)
+ val params = binding.singlePreviewView.layoutParams
+ container.removeView(binding.singlePreviewView)
+ container.addView(activityView, index, params)
+ activityView.isVisible = true
+ activityView.setViewCompositionStrategy(
+ ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
+ )
+ singlePreviewView = activityView
+ singleRenderer = ComposableRenderer(activityView)
+ }
+
+ private fun setupBuildButtons() {
+ binding.buildProjectButton.setOnClickListener { triggerBuild() }
+ binding.errorBuildButton.setOnClickListener { triggerBuild() }
+ binding.copyErrorButton.setOnClickListener { copyError() }
+ }
+
+ private fun copyError() {
+ val text = lastErrorText.ifBlank { binding.errorMessage.text?.toString().orEmpty() }
+ val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
+ clipboard?.setPrimaryClip(ClipData.newPlainText("Compose preview error", text))
+ Toast.makeText(requireContext(), "Error copied to clipboard", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun triggerBuild() {
+ if (buildTriggered) return
+ val modulePath = viewModel.getModulePath()
+ val variantName = viewModel.getVariantName()
+ val buildService = PluginFragmentHelper
+ .getServiceRegistry(ComposePreviewPlugin.PLUGIN_ID)
+ ?.get(IdeBuildService::class.java)
+ if (buildService == null) {
+ viewModel.setBuildFailed()
+ return
+ }
+ if (buildService.isBuildInProgress()) return
+
+ buildTriggered = true
+ viewModel.setBuildingState()
+ val variant = variantName.replaceFirstChar { it.uppercaseChar() }
+ val task = if (modulePath.isNotEmpty()) "$modulePath:assemble$variant" else "assemble$variant"
+ LOG.info("Compose preview triggering build: {}", task)
+
+ buildService.executeTasks(task).whenComplete { success, error ->
+ view?.post {
+ buildTriggered = false
+ if (error == null && success == true) {
+ viewModel.refreshAfterBuild(pluginContext)
+ } else {
+ LOG.error("Compose preview build failed", error)
+ viewModel.setBuildFailed()
+ }
+ }
+ }
+ }
+
+ private fun observeState() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.previewState.collect { handlePreviewState(it) }
+ }
+ }
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.displayMode.collect { updateDisplayMode(it) }
+ }
+ }
+ }
+
+ 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.singlePreviewWrapper.isVisible = isReady && !isAllMode
+
+ when (state) {
+ is PreviewState.Idle -> setStatus("Rendering…")
+ is PreviewState.Initializing -> setStatus(getString(ResourcesR.string.preview_initializing))
+ is PreviewState.Compiling -> setStatus("Compiling…")
+ is PreviewState.Building -> setStatus(
+ getString(ResourcesR.string.preview_building_project),
+ "First build may take a few minutes"
+ )
+ is PreviewState.NeedsBuild -> { /* needsBuildContainer + Build button handle this */ }
+ is PreviewState.Empty -> { /* emptyContainer handles this */ }
+ is PreviewState.Ready -> loadAndRender(state)
+ is PreviewState.Error -> showError(state)
+ }
+ }
+
+ private fun setStatus(text: String, subtext: String? = null) {
+ binding.statusText.text = text
+ binding.statusSubtext.text = subtext ?: ""
+ binding.statusSubtext.isVisible = subtext != null
+ binding.loadingIndicator.isVisible = true
+ }
+
+ private fun showError(state: PreviewState.Error) {
+ // Short headline only — the full output goes in the scrollable details + Copy button,
+ // so a long compiler error never pushes the action buttons off-screen.
+ binding.errorMessage.text = if (state.diagnostics.isNotEmpty()) {
+ "Compilation failed — ${state.diagnostics.size} issue(s)"
+ } else {
+ state.message.lineSequence().firstOrNull { it.isNotBlank() }?.trim()?.take(160)
+ ?: "Preview error"
+ }
+ val details = if (state.diagnostics.isNotEmpty()) {
+ state.diagnostics.joinToString("\n\n") { d ->
+ buildString {
+ if (d.file != null || d.line != null) {
+ d.file?.let { append(it.substringAfterLast('/')) }
+ d.line?.let { append(":$it") }
+ d.column?.let { append(":$it") }
+ append("\n")
+ }
+ append("[${d.severity}] ${d.message}")
+ }
+ }
+ } else {
+ state.message
+ }
+ // Full, scrollable + selectable details, and the same text feeds the Copy button so a
+ // long error that overflows the view is always recoverable.
+ val full = if (details.isNotBlank() && details != state.message) {
+ "${state.message}\n\n$details"
+ } else {
+ state.message
+ }
+ lastErrorText = full
+ binding.errorDetails.text = full
+ binding.errorDetails.isVisible = true
+ binding.errorBuildButton.isVisible = viewModel.canTriggerBuild()
+ LOG.error("Preview error: {}", state.message)
+ }
+
+ private fun updateDisplayMode(mode: DisplayMode) {
+ val isAllMode = mode == DisplayMode.ALL
+ toggleMenuItem?.apply {
+ // Label/icon describe the mode you switch TO, so the action reads clearly.
+ setIcon(if (isAllMode) R.drawable.ic_view_single else R.drawable.ic_view_grid)
+ title = if (isAllMode) "Single" else "All"
+ }
+ refreshSelector()
+
+ if (viewModel.previewState.value is PreviewState.Ready) {
+ binding.previewScrollView.isVisible = isAllMode
+ binding.singlePreviewWrapper.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
+ loadedClass = null
+ loadJob?.cancel()
+ loadJob = viewLifecycleOwner.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 ?: run {
+ 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 =
+ state.previewConfigs.flatMap { config -> instancesForConfig(config, state) }
+
+ private fun instancesForConfig(config: PreviewConfig, state: PreviewState.Ready): List {
+ val factory = resourceContextFactory ?: return emptyList()
+ val context = factory.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(requireActivity().resources.configuration)
+ // Previews must be deterministic and independent of the IDE's day/night theme — the way
+ // Studio renders them. Default night mode to NIGHT_NO (light) and only switch to dark when
+ // the @Preview's uiMode explicitly asks for it; otherwise the preview inherits the IDE's
+ // dark mode and every preview (including "light" ones) renders dark.
+ val requestedType = config.uiMode?.and(Configuration.UI_MODE_TYPE_MASK) ?: 0
+ val requestedNight = config.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK) ?: 0
+ var merged = configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()
+ merged = merged or (if (requestedNight != 0) requestedNight else Configuration.UI_MODE_NIGHT_NO)
+ if (requestedType != 0) {
+ merged = (merged and Configuration.UI_MODE_TYPE_MASK.inv()) or requestedType
+ }
+ 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) ?: return emptyList()
+ val instance = providerClass.getDeclaredConstructor().newInstance()
+ val values = providerClass.getMethod("getValues").invoke(instance) as? Sequence<*> ?: return emptyList()
+ values.take(minOf(limit, MAX_PARAMETER_VALUES)).toList()
+ } 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 }
+
+ if (keys == renderedKeys && multiRenderers.keys == keys.toSet()) {
+ instances.forEach { instance ->
+ multiRenderers[instance.cardKey]?.render(
+ clazz, instance.config.functionName, instance.context,
+ instance.parameterValue, instance.config.parameterIndex
+ )
+ }
+ return
+ }
+
+ container.removeAllViews()
+ multiRenderers.clear()
+ renderedKeys = keys
+
+ val inflater = PluginFragmentHelper.getPluginInflater(ComposePreviewPlugin.PLUGIN_ID, layoutInflater)
+ instances.forEachIndexed { index, instance ->
+ val item = createPreviewItem(inflater, container, instance, index == 0)
+ container.addView(item)
+
+ val bounded = item.findViewById(R.id.composePreview)
+ val cv = swapToActivityComposeView(bounded)
+ applyCardAttributes(bounded, cv, instance.config)
+ cv.setViewCompositionStrategy(
+ ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
+ )
+
+ val renderer = ComposableRenderer(cv)
+ multiRenderers[instance.cardKey] = renderer
+ renderer.render(
+ clazz, instance.config.functionName, instance.context,
+ instance.parameterValue, instance.config.parameterIndex
+ )
+ }
+ }
+
+ private fun renderSinglePreview() {
+ val clazz = loadedClass ?: return
+ val instance = previewInstances.firstOrNull { it.cardKey == selectedSingleKey }
+ ?: previewInstances.firstOrNull()
+ ?: return
+ selectedSingleKey = instance.cardKey
+ binding.singlePreviewLabel.text = buildString {
+ append(instance.label)
+ instance.config.group?.let { append(" · ").append(it) }
+ }
+ singlePreviewView?.let { applyBackground(it, instance.config) }
+ singleRenderer?.render(
+ clazz, instance.config.functionName, instance.context,
+ instance.parameterValue, instance.config.parameterIndex
+ )
+ }
+
+ /** Exact replica of the module's createPreviewItem — inflates item_preview_card.xml. */
+ private fun createPreviewItem(
+ inflater: LayoutInflater,
+ container: ViewGroup,
+ instance: PreviewInstance,
+ isFirst: Boolean
+ ): View {
+ val item = inflater.inflate(R.layout.item_preview_card, container, 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)?.isVisible = !isFirst
+ return item
+ }
+
+ /**
+ * Replace the inflated BoundedComposeView's inner ComposeView with one backed by the host
+ * Activity, so previewed theme code that does (view.context as Activity) works. The card's
+ * item_preview_card.xml layout — label, divider, frame, BoundedComposeView sizing — is
+ * otherwise used exactly as the module defines it.
+ */
+ private fun swapToActivityComposeView(bounded: BoundedComposeView): ComposeView {
+ bounded.removeAllViews()
+ val cv = ComposeView(requireActivity()).apply {
+ layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT
+ )
+ }
+ bounded.addView(cv)
+ return cv
+ }
+
+ private fun applyCardAttributes(bounded: BoundedComposeView, composeView: View, config: PreviewConfig) {
+ val density = requireActivity().resources.displayMetrics.density
+ bounded.explicitWidthPx = config.widthDp?.let { (it * density).toInt() }
+ bounded.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 Color.TRANSPARENT
+ )
+ }
+
+ private fun resolveBackgroundColor(raw: Long?): Int {
+ if (raw == null || raw == 0L) return Color.WHITE
+ val argb = raw.toInt()
+ return if ((argb ushr 24) == 0) argb or OPAQUE_ALPHA else argb
+ }
+
+ fun updateSource(source: String) {
+ sourceCode = source
+ viewModel.onSourceChanged(source)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ loadJob?.cancel()
+ loadJob = null
+ loadedClass = null
+ previewInstances = emptyList()
+ renderedKeys = emptyList()
+ resourceContextFactory?.release()
+ resourceContextFactory = null
+ multiRenderers.clear()
+ singleRenderer = null
+ singlePreviewView = null
+ classLoader?.release()
+ classLoader = null
+ selectorAdapter = null
+ toggleMenuItem = null
+ _binding = null
+ }
+
+ 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
+ }
+
+ companion object {
+ private val LOG = LoggerFactory.getLogger(ComposePreviewFragment::class.java)
+ private const val OPAQUE_ALPHA = 0xFF shl 24
+ private const val MAX_PARAMETER_VALUES = 25
+
+ private const val DEFAULT_SOURCE = """
+package preview
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+
+@Composable
+fun Preview() {
+ Text("Hello, Compose Preview!")
+}
+"""
+ }
+}
diff --git a/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewPlugin.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewPlugin.kt
new file mode 100644
index 00000000..413d58be
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewPlugin.kt
@@ -0,0 +1,130 @@
+package org.appdevforall.composepreview
+
+import android.content.Context
+import com.itsaky.androidide.plugins.IPlugin
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.extensions.DocumentationExtension
+import com.itsaky.androidide.plugins.extensions.PluginTooltipButton
+import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry
+import com.itsaky.androidide.plugins.extensions.ShowAsAction
+import com.itsaky.androidide.plugins.extensions.ToolbarAction
+import com.itsaky.androidide.plugins.extensions.ToolbarActionIds
+import com.itsaky.androidide.plugins.extensions.UIExtension
+import com.itsaky.androidide.plugins.services.IdeEditorService
+import com.itsaky.androidide.plugins.services.IdeEnvironmentService
+import com.itsaky.androidide.plugins.services.IdeUIService
+
+/**
+ * Entry point for the extracted Jetpack Compose preview feature.
+ *
+ * Surfaces a content-aware editor toolbar action / menu item (enabled only for a `.kt`
+ * file containing `@Preview`) that opens the renderer full-screen via
+ * [IdeUIService.openPluginScreen]. Depends only on the plugin-api contract.
+ */
+class ComposePreviewPlugin : IPlugin, UIExtension, DocumentationExtension {
+
+ private lateinit var context: PluginContext
+
+ override fun initialize(context: PluginContext): Boolean {
+ this.context = context
+ pluginAndroidContext = context.androidContext
+ Environment.init(context.androidContext, context.services.get(IdeEnvironmentService::class.java))
+ context.logger.info("ComposePreviewPlugin initialized")
+ return true
+ }
+
+ override fun activate(): Boolean = true
+
+ override fun deactivate(): Boolean = true
+
+ override fun dispose() = Unit
+
+ override fun getToolbarActions(): List = listOf(
+ ToolbarAction(
+ id = "compose_preview",
+ title = "Compose Preview",
+ icon = R.drawable.ic_compose_preview,
+ showAsAction = ShowAsAction.IF_ROOM,
+ // Matches PreviewLayoutAction's toolbar slot so the Compose icon takes the
+ // preview position; it is only shown for Compose files (see provider below),
+ // and on those files the built-in preview is hidden via getHiddenToolbarActionIds.
+ order = COMPOSE_PREVIEW_ORDER,
+ action = { openPreviewIfValid() },
+ ).apply {
+ isVisibleProvider = { hasComposePreview() }
+ }
+ )
+
+ override fun getHiddenToolbarActionIds(): Set =
+ if (hasComposePreview()) setOf(ToolbarActionIds.PREVIEW_LAYOUT) else emptySet()
+
+ // Documentation shown when the user long-presses the toolbar action. The tag matches the one
+ // the host derives for this action (pluginTooltipTag = "."), and
+ // entries are stored under the plugin's category ("plugin_"), so the host's existing
+ // long-press tooltip lookup resolves to these. Content mirrors the built-in
+ // ide/editor.compose.preview tooltip: summary = tier one, detail = tier two ("See More").
+ override fun getTooltipCategory(): String = "plugin_$PLUGIN_ID"
+
+ override fun getTooltipEntries(): List = listOf(
+ PluginTooltipEntry(
+ tag = "$PLUGIN_ID.compose_preview",
+ summary = "Preview the Compose layout.",
+ detail = "See how your Compose UI looks while you build it, without running the app on a device or emulator.",
+ buttons = listOf(
+ PluginTooltipButton(
+ description = "How it works",
+ uri = "index.html",
+ order = 0,
+ ),
+ PluginTooltipButton(
+ description = "Learn More",
+ uri = "i/plugins-adfa.html",
+ order = 1,
+ directPath = true,
+ )
+ )
+ )
+ )
+
+ // Tier 3 documentation bundle: files under assets/docs/ are indexed at install time and served
+ // from http://localhost:6174/plugin//. The "How it works" button links index.html.
+ override fun getTier3DocsAssetPath(): String = "docs"
+
+ private fun openPreviewIfValid() {
+ val editor = context.services.get(IdeEditorService::class.java)
+ val file = editor?.getCurrentFile()
+ val source = editor?.getCurrentFileContent()
+ if (file == null || source == null || !isComposePreviewSource(file.name, source)) {
+ context.logger.warn("Compose Preview requires a .kt file containing @Preview")
+ return
+ }
+
+ ComposePreviewState.set(file.absolutePath, source)
+ context.services.get(IdeUIService::class.java)
+ ?.openPluginScreen(PLUGIN_ID, ComposePreviewFragment::class.java.name, "Compose Preview")
+ }
+
+ private fun hasComposePreview(): Boolean {
+ val editor = context.services.get(IdeEditorService::class.java) ?: return false
+ val file = editor.getCurrentFile() ?: return false
+ val source = editor.getCurrentFileContent() ?: return false
+ return isComposePreviewSource(file.name, source)
+ }
+
+ private fun isComposePreviewSource(fileName: String, source: String): Boolean =
+ fileName.endsWith(".kt") && PREVIEW_REGEX.containsMatchIn(source)
+
+ companion object {
+ const val PLUGIN_ID = "org.appdevforall.composepreview"
+
+ // Built-in PreviewLayoutAction is registered at toolbar order index 7; matching it
+ // places the Compose preview icon in the same slot it replaces on Compose files.
+ private const val COMPOSE_PREVIEW_ORDER = 7
+
+ private val PREVIEW_REGEX = Regex("""@Preview\b""")
+
+ @Volatile
+ var pluginAndroidContext: Context? = null
+ private set
+ }
+}
diff --git a/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewState.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewState.kt
new file mode 100644
index 00000000..6bde6a84
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewState.kt
@@ -0,0 +1,22 @@
+package org.appdevforall.composepreview
+
+/**
+ * Hands the current file + source from the toolbar/menu action to [ComposePreviewFragment]
+ * across the openPluginScreen boundary (which carries no Bundle). Mirrors the
+ * SketchToUiState pattern used by sketch-to-ui-plugin.
+ */
+object ComposePreviewState {
+
+ @Volatile
+ var filePath: String? = null
+ private set
+
+ @Volatile
+ var sourceCode: String? = null
+ private set
+
+ fun set(filePath: String?, sourceCode: String?) {
+ this.filePath = filePath
+ this.sourceCode = sourceCode
+ }
+}
diff --git a/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewViewModel.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewViewModel.kt
new file mode 100644
index 00000000..4116bbc9
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ComposePreviewViewModel.kt
@@ -0,0 +1,340 @@
+package org.appdevforall.composepreview
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import org.appdevforall.composepreview.compiler.CompileDiagnostic
+import org.appdevforall.composepreview.data.repository.CompilationException
+import org.appdevforall.composepreview.data.repository.ComposePreviewRepository
+import org.appdevforall.composepreview.data.repository.ComposePreviewRepositoryImpl
+import org.appdevforall.composepreview.data.repository.InitializationResult
+import org.appdevforall.composepreview.domain.PreviewSourceParser
+import org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/Environment.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/Environment.kt
new file mode 100644
index 00000000..e2972679
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/Environment.kt
@@ -0,0 +1,53 @@
+package org.appdevforall.composepreview
+
+import android.content.Context
+import com.itsaky.androidide.plugins.services.IdeEnvironmentService
+import java.io.File
+
+/**
+ * Plugin-local replacement for the host `com.itsaky.androidide.utils.Environment`.
+ *
+ * Keeps the renderer independent of host-internal classes: the plugin runs inside the
+ * host process, so [Context.getFilesDir] is the host data dir (`/data/data//files`)
+ * where the bundled SDK + JDK live, and the SDK root is also available through the
+ * plugin-api [IdeEnvironmentService]. Initialized once from [ComposePreviewPlugin].
+ */
+object Environment {
+
+ private lateinit var appContext: Context
+ private var ideEnv: IdeEnvironmentService? = null
+
+ fun init(context: Context, ideEnvironment: IdeEnvironmentService?) {
+ appContext = context.applicationContext ?: context
+ ideEnv = ideEnvironment
+ }
+
+ val HOME: File
+ get() = File(appContext.filesDir, "home")
+
+ val ANDROID_HOME: File
+ get() = ideEnv?.getAndroidHomeDirectory() ?: File(HOME, "android-sdk")
+
+ /** `/usr/lib/jvm/java-21-openjdk/bin/java` — matches host DEFAULT_JAVA_HOME. */
+ val JAVA: File
+ get() = File(appContext.filesDir, "usr/lib/jvm/java-21-openjdk/bin/java")
+
+ /** Highest android.jar found under the SDK platforms directory. */
+ val ANDROID_JAR: File
+ get() {
+ val platforms = File(ANDROID_HOME, "platforms")
+ val resolved = platforms.listFiles()
+ ?.filter { it.isDirectory }
+ ?.sortedByDescending { it.name }
+ ?.firstNotNullOfOrNull { dir -> File(dir, "android.jar").takeIf { it.exists() } }
+ return resolved ?: File(platforms, "android-34/android.jar")
+ }
+
+ /** Plugin-writable dir the bundled compose-jars.zip is extracted into. */
+ val COMPOSE_HOME: File
+ get() {
+ val base = runCatching { ideEnv?.getPluginDataDirectory() }.getOrNull()
+ ?: appContext.cacheDir
+ return File(base, "compose").apply { mkdirs() }
+ }
+}
diff --git a/compose-preview/src/main/kotlin/org/appdevforall/composepreview/compiler/CompilerDaemon.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/compiler/CompilerDaemon.kt
new file mode 100644
index 00000000..fb802058
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/compiler/CompilerDaemon.kt
@@ -0,0 +1,448 @@
+package org.appdevforall.composepreview.compiler
+
+import org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/compiler/ComposeClasspathManager.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/compiler/ComposeClasspathManager.kt
new file mode 100644
index 00000000..a059e704
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/compiler/ComposeClasspathManager.kt
@@ -0,0 +1,327 @@
+package org.appdevforall.composepreview.compiler
+
+import android.content.Context
+import org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/compiler/ComposeCompiler.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/compiler/ComposeCompiler.kt
new file mode 100644
index 00000000..0d037bb6
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/compiler/ComposeCompiler.kt
@@ -0,0 +1,236 @@
+package org.appdevforall.composepreview.compiler
+
+import org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/compiler/ComposeDexCompiler.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/compiler/ComposeDexCompiler.kt
new file mode 100644
index 00000000..f20e375b
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/compiler/ComposeDexCompiler.kt
@@ -0,0 +1,150 @@
+package org.appdevforall.composepreview.compiler
+
+import org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/compiler/DexCache.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/compiler/DexCache.kt
new file mode 100644
index 00000000..04f94470
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/compiler/DexCache.kt
@@ -0,0 +1,98 @@
+package org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/data/repository/ComposePreviewRepository.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/data/repository/ComposePreviewRepository.kt
new file mode 100644
index 00000000..11af01c0
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/data/repository/ComposePreviewRepository.kt
@@ -0,0 +1,47 @@
+package org.appdevforall.composepreview.data.repository
+
+import android.content.Context
+import org.appdevforall.composepreview.compiler.CompileDiagnostic
+import org.appdevforall.composepreview.data.source.ProjectContext
+import org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/data/repository/ComposePreviewRepositoryImpl.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/data/repository/ComposePreviewRepositoryImpl.kt
new file mode 100644
index 00000000..017f06e8
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/data/repository/ComposePreviewRepositoryImpl.kt
@@ -0,0 +1,276 @@
+package org.appdevforall.composepreview.data.repository
+
+import android.content.Context
+import org.appdevforall.composepreview.compiler.CompileDiagnostic
+import org.appdevforall.composepreview.compiler.CompilerDaemon
+import org.appdevforall.composepreview.compiler.ComposeClasspathManager
+import org.appdevforall.composepreview.compiler.ComposeCompiler
+import org.appdevforall.composepreview.compiler.ComposeDexCompiler
+import org.appdevforall.composepreview.compiler.DexCache
+import org.appdevforall.composepreview.data.source.ProjectContext
+import org.appdevforall.composepreview.data.source.ProjectContextSource
+import org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/data/source/ProjectContextSource.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/data/source/ProjectContextSource.kt
new file mode 100644
index 00000000..901eac9a
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/data/source/ProjectContextSource.kt
@@ -0,0 +1,61 @@
+package org.appdevforall.composepreview.data.source
+
+import com.itsaky.androidide.plugins.base.PluginFragmentHelper
+import com.itsaky.androidide.plugins.services.IdeProjectService
+import org.appdevforall.composepreview.ComposePreviewPlugin
+import org.slf4j.LoggerFactory
+import java.io.File
+
+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
+)
+
+/**
+ * Resolves the build context for a source file through the plugin-api
+ * [IdeProjectService.getModuleContext] — keeping the plugin independent of host-internal
+ * project types. The classpath/variant/resource-APK resolution lives host-side in the
+ * IDE's ModuleContextResolver.
+ */
+class ProjectContextSource {
+
+ fun resolveContext(filePath: String): ProjectContext {
+ val service = PluginFragmentHelper
+ .getServiceRegistry(ComposePreviewPlugin.PLUGIN_ID)
+ ?.get(IdeProjectService::class.java)
+
+ val moduleContext = if (filePath.isBlank()) null else service?.getModuleContext(filePath)
+
+ if (moduleContext == null) {
+ LOG.info("No module context for '{}' (service available: {})", filePath, service != null)
+ return EMPTY
+ }
+
+ return ProjectContext(
+ modulePath = moduleContext.modulePath,
+ variantName = moduleContext.variantName,
+ compileClasspaths = moduleContext.compileClasspaths,
+ intermediateClasspaths = moduleContext.intermediateClasspaths.toSet(),
+ projectDexFiles = moduleContext.runtimeDexFiles,
+ needsBuild = moduleContext.needsBuild,
+ resourceApk = moduleContext.resourceApk
+ )
+ }
+
+ companion object {
+ private val LOG = LoggerFactory.getLogger(ProjectContextSource::class.java)
+ private val EMPTY = ProjectContext(
+ modulePath = null,
+ variantName = "debug",
+ compileClasspaths = emptyList(),
+ intermediateClasspaths = emptySet(),
+ projectDexFiles = emptyList(),
+ needsBuild = false
+ )
+ }
+}
diff --git a/compose-preview/src/main/kotlin/org/appdevforall/composepreview/domain/PreviewSourceParser.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/domain/PreviewSourceParser.kt
new file mode 100644
index 00000000..50cb2de3
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/domain/PreviewSourceParser.kt
@@ -0,0 +1,281 @@
+package org.appdevforall.composepreview.domain
+
+import org.appdevforall.composepreview.PreviewConfig
+import org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/domain/model/ParsedPreviewSource.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/domain/model/ParsedPreviewSource.kt
new file mode 100644
index 00000000..b270ac89
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/domain/model/ParsedPreviewSource.kt
@@ -0,0 +1,9 @@
+package org.appdevforall.composepreview.domain.model
+
+import org.appdevforall.composepreview.PreviewConfig
+
+data class ParsedPreviewSource(
+ val packageName: String,
+ val className: String?,
+ val previewConfigs: List
+)
diff --git a/compose-preview/src/main/kotlin/org/appdevforall/composepreview/runtime/ComposableInvoker.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/runtime/ComposableInvoker.kt
new file mode 100644
index 00000000..085c29ad
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/runtime/ComposableInvoker.kt
@@ -0,0 +1,134 @@
+package org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/runtime/ComposableRenderer.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/runtime/ComposableRenderer.kt
new file mode 100644
index 00000000..01a22e2e
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/runtime/ComposableRenderer.kt
@@ -0,0 +1,208 @@
+package org.appdevforall.composepreview.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.DisposableEffect
+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.platform.LocalInspectionMode
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+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
+ )
+ // Synthetic, isolated preview owner so composables that use viewModel() /
+ // lifecycle get a controlled, always-RESUMED environment instead of the host's.
+ val previewOwner = remember { PreviewStateOwner() }
+ DisposableEffect(Unit) { onDispose { previewOwner.clear() } }
+ CompositionLocalProvider(
+ // Signal "this is a preview" so well-behaved composables skip runtime-only
+ // work (Activity/window access, analytics, network) the way Studio's preview does.
+ LocalInspectionMode provides true,
+ LocalContext provides previewContext,
+ LocalConfiguration provides previewConfiguration,
+ LocalDensity provides previewDensity,
+ LocalLifecycleOwner provides previewOwner,
+ LocalViewModelStoreOwner provides previewOwner
+ ) {
+ 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"}"
+ }
+
+ /**
+ * A self-contained owner for the preview composition: an always-RESUMED lifecycle and an
+ * isolated ViewModelStore. Keeps previews that call viewModel()/observe lifecycle from
+ * binding to (and polluting) the host Activity's real owners.
+ */
+ private class PreviewStateOwner : LifecycleOwner, ViewModelStoreOwner {
+ private val registry = LifecycleRegistry(this).apply {
+ currentState = Lifecycle.State.RESUMED
+ }
+ override val lifecycle: Lifecycle get() = registry
+ override val viewModelStore: ViewModelStore = ViewModelStore()
+
+ fun clear() {
+ viewModelStore.clear()
+ registry.currentState = Lifecycle.State.DESTROYED
+ }
+ }
+
+ 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/kotlin/org/appdevforall/composepreview/runtime/ComposeClassLoader.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/runtime/ComposeClassLoader.kt
new file mode 100644
index 00000000..0e493171
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/runtime/ComposeClassLoader.kt
@@ -0,0 +1,130 @@
+package org.appdevforall.composepreview.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()
+
+ // §4.4: parent to the PLUGIN's own classloader (not the host's) so the previewed
+ // composable resolves androidx.compose.* from the plugin's bundled dex. The host
+ // no longer ships Compose after extraction, so parenting to it would NoClassDefFound.
+ val pluginParent = ComposeClassLoader::class.java.classLoader ?: context.classLoader
+ val loader = DexClassLoader(
+ dexPath,
+ optimizedDir.absolutePath,
+ null,
+ pluginParent
+ )
+
+ 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/kotlin/org/appdevforall/composepreview/runtime/ComposeSignature.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/runtime/ComposeSignature.kt
new file mode 100644
index 00000000..7804ccee
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/runtime/ComposeSignature.kt
@@ -0,0 +1,38 @@
+package org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/runtime/ProjectResourceContextFactory.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/runtime/ProjectResourceContextFactory.kt
new file mode 100644
index 00000000..01666408
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/runtime/ProjectResourceContextFactory.kt
@@ -0,0 +1,86 @@
+package org.appdevforall.composepreview.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/kotlin/org/appdevforall/composepreview/ui/BoundedComposeView.kt b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ui/BoundedComposeView.kt
new file mode 100644
index 00000000..afa060e0
--- /dev/null
+++ b/compose-preview/src/main/kotlin/org/appdevforall/composepreview/ui/BoundedComposeView.kt
@@ -0,0 +1,61 @@
+package org.appdevforall.composepreview.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_close.xml b/compose-preview/src/main/res/drawable/ic_close.xml
new file mode 100644
index 00000000..7a0ff35d
--- /dev/null
+++ b/compose-preview/src/main/res/drawable/ic_close.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/compose-preview/src/main/res/drawable/ic_compose_preview.xml b/compose-preview/src/main/res/drawable/ic_compose_preview.xml
new file mode 100644
index 00000000..f4119a4b
--- /dev/null
+++ b/compose-preview/src/main/res/drawable/ic_compose_preview.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/compose-preview/src/main/res/drawable/ic_error.xml b/compose-preview/src/main/res/drawable/ic_error.xml
new file mode 100755
index 00000000..9f7893d7
--- /dev/null
+++ b/compose-preview/src/main/res/drawable/ic_error.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/compose-preview/src/main/res/drawable/ic_gradle.xml b/compose-preview/src/main/res/drawable/ic_gradle.xml
new file mode 100644
index 00000000..57aec550
--- /dev/null
+++ b/compose-preview/src/main/res/drawable/ic_gradle.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
diff --git a/compose-preview/src/main/res/drawable/ic_preview_layout.xml b/compose-preview/src/main/res/drawable/ic_preview_layout.xml
new file mode 100644
index 00000000..f4119a4b
--- /dev/null
+++ b/compose-preview/src/main/res/drawable/ic_preview_layout.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/compose-preview/src/main/res/drawable/ic_view_grid.xml b/compose-preview/src/main/res/drawable/ic_view_grid.xml
new file mode 100644
index 00000000..7ea321ef
--- /dev/null
+++ b/compose-preview/src/main/res/drawable/ic_view_grid.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/compose-preview/src/main/res/drawable/ic_view_single.xml b/compose-preview/src/main/res/drawable/ic_view_single.xml
new file mode 100644
index 00000000..5612f7c0
--- /dev/null
+++ b/compose-preview/src/main/res/drawable/ic_view_single.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/compose-preview/src/main/res/layout/fragment_compose_preview.xml b/compose-preview/src/main/res/layout/fragment_compose_preview.xml
new file mode 100644
index 00000000..216f8bfa
--- /dev/null
+++ b/compose-preview/src/main/res/layout/fragment_compose_preview.xml
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/compose-preview/src/main/res/layout/item_preview_card.xml b/compose-preview/src/main/res/layout/item_preview_card.xml
new file mode 100644
index 00000000..e606c6f1
--- /dev/null
+++ b/compose-preview/src/main/res/layout/item_preview_card.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/compose-preview/src/main/res/menu/menu_compose_preview.xml b/compose-preview/src/main/res/menu/menu_compose_preview.xml
new file mode 100644
index 00000000..98328b99
--- /dev/null
+++ b/compose-preview/src/main/res/menu/menu_compose_preview.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/compose-preview/src/main/res/values/strings.xml b/compose-preview/src/main/res/values/strings.xml
new file mode 100644
index 00000000..6b9de432
--- /dev/null
+++ b/compose-preview/src/main/res/values/strings.xml
@@ -0,0 +1,16 @@
+
+
+ Compose Preview
+
+
+ Compose Preview
+ Toggle preview mode
+ Initializing Compose preview…
+ No @Preview composables found
+ Add @Preview annotation to a @Composable function to see it here
+ Building project…
+ Build Required
+ Build the project to enable multi-file preview support
+ Build Project
+ Preview Error
+
diff --git a/compose-preview/src/main/res/values/styles.xml b/compose-preview/src/main/res/values/styles.xml
new file mode 100644
index 00000000..34cf7bc5
--- /dev/null
+++ b/compose-preview/src/main/res/values/styles.xml
@@ -0,0 +1,4 @@
+
+
+
+