diff --git a/README.md b/README.md index a1ed789e..4e1a9108 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`client-time-tracker/`](client-time-tracker/) | Tracks billable coding sessions per project and generates PDF/Excel/CSV invoices. | | [`python-tools/`](python-tools/) | Adds Python + Flask project templates, with on-device Python install and run/install/test actions. | | [`rainbow-on-the-go/`](rainbow-on-the-go/) | Colors matching parentheses, brackets, and braces by nesting depth, with light/dark palettes. | +| [`compose-preview/`](compose-preview/) | Renders Jetpack Compose `@Preview` functions on-device — no full app build or run. | | [`ai-literacy-course/`](ai-literacy-course/) | Bundles Learn AI Anywhere's offline "Introduction to AI" course (26 videos + interactive activities) and plays it full-screen, fully offline. | ## Building a plugin diff --git a/compose-preview/build.gradle.kts b/compose-preview/build.gradle.kts new file mode 100644 index 00000000..12086457 --- /dev/null +++ b/compose-preview/build.gradle.kts @@ -0,0 +1,102 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("com.itsaky.androidide.plugins.build") +} + +pluginBuilder { + pluginName = "compose-preview" +} + +android { + namespace = "org.appdevforall.composepreview" + compileSdk = 34 + + defaultConfig { + applicationId = "org.appdevforall.composepreview" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a") + } + } + + buildTypes { + release { + isMinifyEnabled = false + isShrinkResources = false + signingConfig = signingConfigs.getByName("debug") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + viewBinding = true + } + + // Keep the bundled on-device Compose toolchain archive uncompressed inside the .cgp. + androidResources { + noCompress += "zip" + } + + packaging { + resources { + excludes += setOf( + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/INDEX.LIST", + "META-INF/*.kotlin_module" + ) + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + // Host provides plugin-api implementations at runtime; the plugin links only against + // the stable plugin-api contract — never host-internal modules. + compileOnly(files("../libs/plugin-api.jar")) + + // Compose is BUNDLED into the .cgp because the host no longer ships it after extraction. + val composeBom = platform("androidx.compose:compose-bom:2024.02.00") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.runtime:runtime") + implementation("androidx.activity:activity-compose:1.8.2") + + implementation("androidx.fragment:fragment-ktx:1.8.8") + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + implementation("com.google.android.material:material:1.10.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("org.slf4j:slf4j-nop:1.7.36") + + debugImplementation(composeBom) + debugImplementation("androidx.compose.ui:ui-tooling") +} diff --git a/compose-preview/compose-preview-documentation.html b/compose-preview/compose-preview-documentation.html new file mode 100644 index 00000000..f6d8cdc8 --- /dev/null +++ b/compose-preview/compose-preview-documentation.html @@ -0,0 +1,435 @@ + + + + + +Compose Preview Plugin Documentation + + + + +
+

Compose Preview Plugin

+

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

+ + + + + + + + +
ModeWhat it shows
All previewsEvery @Preview in the file, each rendered as a labelled card in a scrolling list.
Single previewOne @Preview chosen from a selector, rendered on its own.
+ +

Per-preview options honoured

+

+ Each preview's annotation parameters are applied when it renders: +

+ + + + + + + + + + + +
ParameterEffect
showBackgroundDraws the default surface behind the composable.
uiModeLight or dark configuration (see below).
fontScaleScales text to preview accessibility sizes.
widthDp / heightDpConstrains the render surface.
@PreviewParameterEach 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. +

+
@Preview(showBackground = true, name = "Greeting")
+@Composable
+fun GreetingPreview() {
+    MyTheme { Greeting("Android") }
+}
+
+@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun GreetingDarkPreview() {
+    MyTheme(darkTheme = true) { Greeting("Android") }
+}
+ +

When a build is needed

+

+ 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. +

+
+ + +
+

3. Technical Architecture

+ +

File layout

+ + +

Class overview

+ + + + + + + + + + + +
ClassRoleKey interfaces
ComposePreviewPluginEntry point. Contributes the toolbar action, the XML-preview hide rule, the long-press tooltip, and the Tier 3 docs.IPlugin, UIExtension, DocumentationExtension
PreviewSourceParserParses the source for @Preview functions, their parameters, and @PreviewParameter providers.None
ComposeCompiler / CompilerDaemonRuns the Kotlin + Compose compiler on-device, then D8 to DEX.None
ComposableRenderer / ComposableInvokerLoads the compiled preview and invokes the composable into a ComposeView under a controlled, always-resumed lifecycle.None
ComposePreviewViewModelDrives 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

+ + + + + + + + + +
SettingValue
Gradle plugincom.itsaky.androidide.plugins.build
Compile / Target SDK34
Min SDK26
Java / Kotlin target17
ComposeEnabled; bundled (compose-bom) so the host need not ship it
Plugin APIcompileOnly 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

+ + + + + + + + + + + +
ServiceUsed for
IdeEditorServiceRead the current file and its contents to detect @Preview.
IdeProjectServicegetModuleContext(): module classpath, variant, and build-output locations for compilation.
IdeBuildServiceexecuteTasks(): run the module's assemble task for the Build Required flow.
IdeUIServiceopenPluginScreen(): present the preview full-screen.
IdeEnvironmentServiceAndroid 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. +

+ +

4.6 Permissions

+
<meta-data android:name="plugin.permissions"
+           android:value="filesystem.read,project.structure,native.code" />
+

+ 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

+
    +
  1. Open Preferences → Plugin Manager → +.
  2. +
  3. Select the compose-preview-debug.cgp file.
  4. +
  5. The IDE discovers ComposePreviewPlugin via manifest metadata and activates it.
  6. +
+ +

Using the plugin

+
    +
  1. Open a .kt file with at least one @Preview in a Compose module.
  2. +
  3. Tap the Compose preview action in the editor toolbar.
  4. +
  5. If prompted with Build Required, tap Build Project once; then the preview renders.
  6. +
  7. Switch between all previews and a single preview from the toolbar / selector.
  8. +
  9. Long-press the action for a quick tooltip; this page is the full reference.
  10. +
+ +

Runtime requirements

+ + + + + + +
RequirementValue
Min Android versionAPI 26 (Android 8)
Min IDE version1.0.0
Permissionsfilesystem.read, project.structure, native.code
Network accessNone
+
+ + +
+

6. Key Benefits

+ +
+ + +
+

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). +

+ +
+ + + + + diff --git a/compose-preview/gradle.properties b/compose-preview/gradle.properties new file mode 100644 index 00000000..04894fe8 --- /dev/null +++ b/compose-preview/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/compose-preview/gradle/wrapper/gradle-wrapper.jar b/compose-preview/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 00000000..8bdaf60c Binary files /dev/null and b/compose-preview/gradle/wrapper/gradle-wrapper.jar differ diff --git a/compose-preview/gradle/wrapper/gradle-wrapper.properties b/compose-preview/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df97d72b --- /dev/null +++ b/compose-preview/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/compose-preview/gradlew b/compose-preview/gradlew new file mode 100755 index 00000000..ef07e016 --- /dev/null +++ b/compose-preview/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/compose-preview/gradlew.bat b/compose-preview/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/compose-preview/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/compose-preview/proguard-rules.pro b/compose-preview/proguard-rules.pro new file mode 100644 index 00000000..d59b5bcd --- /dev/null +++ b/compose-preview/proguard-rules.pro @@ -0,0 +1 @@ +# Compose preview plugin — no minification (release uses isMinifyEnabled = false). diff --git a/compose-preview/settings.gradle.kts b/compose-preview/settings.gradle.kts new file mode 100644 index 00000000..490288c7 --- /dev/null +++ b/compose-preview/settings.gradle.kts @@ -0,0 +1,32 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +buildscript { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + dependencies { + classpath(files("../libs/plugin-api.jar")) + classpath(files("../libs/gradle-plugin.jar")) + classpath("com.android.tools.build:gradle:8.8.2") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0") + classpath("org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.3.0") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "compose-preview" diff --git a/compose-preview/src/main/AndroidManifest.xml b/compose-preview/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0955ae89 --- /dev/null +++ b/compose-preview/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/compose-preview/src/main/assets/compose/compose-jars.zip b/compose-preview/src/main/assets/compose/compose-jars.zip new file mode 100644 index 00000000..577d0f2c Binary files /dev/null and b/compose-preview/src/main/assets/compose/compose-jars.zip differ diff --git a/compose-preview/src/main/assets/docs/index.html b/compose-preview/src/main/assets/docs/index.html new file mode 100644 index 00000000..f6d8cdc8 --- /dev/null +++ b/compose-preview/src/main/assets/docs/index.html @@ -0,0 +1,435 @@ + + + + + +Compose Preview Plugin Documentation + + + + +
+

Compose Preview Plugin

+

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

+ + + + + + + + +
ModeWhat it shows
All previewsEvery @Preview in the file, each rendered as a labelled card in a scrolling list.
Single previewOne @Preview chosen from a selector, rendered on its own.
+ +

Per-preview options honoured

+

+ Each preview's annotation parameters are applied when it renders: +

+ + + + + + + + + + + +
ParameterEffect
showBackgroundDraws the default surface behind the composable.
uiModeLight or dark configuration (see below).
fontScaleScales text to preview accessibility sizes.
widthDp / heightDpConstrains the render surface.
@PreviewParameterEach 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. +

+
@Preview(showBackground = true, name = "Greeting")
+@Composable
+fun GreetingPreview() {
+    MyTheme { Greeting("Android") }
+}
+
+@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun GreetingDarkPreview() {
+    MyTheme(darkTheme = true) { Greeting("Android") }
+}
+ +

When a build is needed

+

+ 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. +

+
+ + +
+

3. Technical Architecture

+ +

File layout

+ + +

Class overview

+ + + + + + + + + + + +
ClassRoleKey interfaces
ComposePreviewPluginEntry point. Contributes the toolbar action, the XML-preview hide rule, the long-press tooltip, and the Tier 3 docs.IPlugin, UIExtension, DocumentationExtension
PreviewSourceParserParses the source for @Preview functions, their parameters, and @PreviewParameter providers.None
ComposeCompiler / CompilerDaemonRuns the Kotlin + Compose compiler on-device, then D8 to DEX.None
ComposableRenderer / ComposableInvokerLoads the compiled preview and invokes the composable into a ComposeView under a controlled, always-resumed lifecycle.None
ComposePreviewViewModelDrives 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

+ + + + + + + + + +
SettingValue
Gradle plugincom.itsaky.androidide.plugins.build
Compile / Target SDK34
Min SDK26
Java / Kotlin target17
ComposeEnabled; bundled (compose-bom) so the host need not ship it
Plugin APIcompileOnly 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

+ + + + + + + + + + + +
ServiceUsed for
IdeEditorServiceRead the current file and its contents to detect @Preview.
IdeProjectServicegetModuleContext(): module classpath, variant, and build-output locations for compilation.
IdeBuildServiceexecuteTasks(): run the module's assemble task for the Build Required flow.
IdeUIServiceopenPluginScreen(): present the preview full-screen.
IdeEnvironmentServiceAndroid 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. +

+ +

4.6 Permissions

+
<meta-data android:name="plugin.permissions"
+           android:value="filesystem.read,project.structure,native.code" />
+

+ 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

+
    +
  1. Open Preferences → Plugin Manager → +.
  2. +
  3. Select the compose-preview-debug.cgp file.
  4. +
  5. The IDE discovers ComposePreviewPlugin via manifest metadata and activates it.
  6. +
+ +

Using the plugin

+
    +
  1. Open a .kt file with at least one @Preview in a Compose module.
  2. +
  3. Tap the Compose preview action in the editor toolbar.
  4. +
  5. If prompted with Build Required, tap Build Project once; then the preview renders.
  6. +
  7. Switch between all previews and a single preview from the toolbar / selector.
  8. +
  9. Long-press the action for a quick tooltip; this page is the full reference.
  10. +
+ +

Runtime requirements

+ + + + + + +
RequirementValue
Min Android versionAPI 26 (Android 8)
Min IDE version1.0.0
Permissionsfilesystem.read, project.structure, native.code
Network accessNone
+
+ + +
+

6. Key Benefits

+ +
+ + +
+

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). +

+ +
+ + + + + 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 @@ + + +