diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableInvoker.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableInvoker.kt new file mode 100644 index 0000000000..2b898ed209 --- /dev/null +++ b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableInvoker.kt @@ -0,0 +1,108 @@ +package com.itsaky.androidide.compose.preview.runtime + +import androidx.compose.runtime.Composer +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Modifier as ReflectModifier +import kotlin.math.ceil + +class PreviewSetupException(message: String, cause: Throwable? = null) : Exception(message, cause) + +object ComposableInvoker { + + fun findComposableMethod(clazz: Class<*>, functionName: String): Method? { + val methods = clazz.declaredMethods + + methods.find { it.name == functionName }?.let { + it.isAccessible = true + return it + } + + val candidates = methods.filter { method -> + !method.name.contains("\$default") && + (method.name.startsWith("$functionName\$") || method.name == "${functionName}\$lambda") + } + + return candidates.minByOrNull { it.parameterCount }?.also { it.isAccessible = true } + } + + fun invokeSafely(clazz: Class<*>, method: Method, composer: Composer) { + 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) + is ComposeSignature.Unsupported -> { + throw PreviewSetupException("Unsupported signature: ${signature.reason}") + } + } + } + + private fun invokeWithComposer( + method: Method, + instance: Any?, + signature: ComposeSignature.WithComposer, + composer: Composer + ) { + val args = arrayOfNulls(signature.totalParams) + val realParamsCount = signature.composerIndex + + for (i in 0 until realParamsCount) { + args[i] = getDefaultValue(signature.types[i]) + } + + 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) + + executeInvocation { method.invoke(instance, *args) } + } + + 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_CHANGED_EVALUATE_ALL = 0 + private const val COMPOSE_DEFAULT_USE_ALL_DEFAULTS = -1 +} diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableRenderer.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableRenderer.kt index ba059e5a8e..6fff2a3c66 100644 --- a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableRenderer.kt +++ b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposableRenderer.kt @@ -10,6 +10,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 @@ -18,7 +20,6 @@ import androidx.compose.ui.unit.dp import org.slf4j.LoggerFactory import java.io.File import java.lang.reflect.Method -import java.lang.reflect.Modifier as ReflectModifier class ComposableRenderer( private val composeView: ComposeView, @@ -39,79 +40,44 @@ class ComposableRenderer( return } - val composableMethod = findComposableMethod(clazz, functionName) + val composableMethod = ComposableInvoker.findComposableMethod(clazz, functionName) if (composableMethod == null) { showError("Composable function not found: $functionName") return } - composeView.setContent { - MaterialTheme { - Surface( - color = MaterialTheme.colorScheme.background - ) { - RenderComposable(clazz, composableMethod) + try { + composeView.setContent { + val errorMessage = remember { mutableStateOf(null) } + + MaterialTheme { + Surface(color = MaterialTheme.colorScheme.background) { + if (errorMessage.value != null) { + ErrorContent(message = errorMessage.value!!) + } else { + RenderComposable(clazz, composableMethod) { exception -> + val cause = exception.cause ?: exception + LOG.error("Reflection error before composition", cause) + errorMessage.value = "Setup failed: ${cause.message ?: cause.javaClass.simpleName}" + } + } + } } } + } catch (e: Exception) { + LOG.error("Preview crashed during initial composition", e) + showError("Preview crashed: ${e.cause?.message ?: e.message}") } - - LOG.debug("Rendered composable: {}#{}", className, functionName) - } - - private 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 } } @Composable - private fun RenderComposable(clazz: Class<*>, method: Method) { - val isStatic = ReflectModifier.isStatic(method.modifiers) - val instance = if (isStatic) { - null - } else { - runCatching { clazz.getDeclaredConstructor().newInstance() }.getOrNull() - } - - if (!isStatic && instance == null) { - LOG.error("Failed to create instance for non-static method: {}", method.name) - ErrorContent("Failed to create instance for ${clazz.simpleName}") - return - } - + private fun RenderComposable(clazz: Class<*>, method: Method, onReflectionError: (Exception) -> Unit) { val composer = currentComposer - val paramCount = method.parameterCount - - val invokeResult: Result = when { - paramCount == 0 -> runCatching { method.invoke(instance) } - paramCount == 2 -> runCatching { method.invoke(instance, composer, 0) } - paramCount > 2 -> runCatching { - val args = arrayOfNulls(paramCount) - args[paramCount - 2] = composer - args[paramCount - 1] = 0 - method.invoke(instance, *args) - } - else -> { - LOG.error("Unexpected parameter count {} for method: {}", paramCount, method.name) - ErrorContent("Unexpected parameter count: $paramCount") - return - } - } - if (invokeResult.isFailure) { - val e = invokeResult.exceptionOrNull() - LOG.error("Failed to invoke composable method: {}", method.name, e) - ErrorContent("Invocation failed: ${e?.message ?: "Unknown error"}") + try { + ComposableInvoker.invokeSafely(clazz, method, composer) + } catch (e: PreviewSetupException) { + onReflectionError(e) } } diff --git a/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposeSignature.kt b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposeSignature.kt new file mode 100644 index 0000000000..c154db7eba --- /dev/null +++ b/compose-preview/src/main/java/com/itsaky/androidide/compose/preview/runtime/ComposeSignature.kt @@ -0,0 +1,38 @@ +package com.itsaky.androidide.compose.preview.runtime + +import java.lang.reflect.Method + +sealed class ComposeSignature { + object NoArgs : ComposeSignature() + + class WithComposer( + val composerIndex: Int, + val totalParams: Int, + val types: Array> + ) : ComposeSignature() + + class Unsupported(val reason: String) : ComposeSignature() + + companion object { + fun analyze(method: Method): ComposeSignature { + val types = method.parameterTypes + val paramCount = types.size + + if (paramCount == 0) return NoArgs + + val composerIndex = types.indexOfFirst { it.name == "androidx.compose.runtime.Composer" } + + if (composerIndex == -1) { + return Unsupported("No Composer parameter found in ${method.name}") + } + + for (i in (composerIndex + 1) until paramCount) { + if (types[i] != Int::class.javaPrimitiveType && types[i] != Integer::class.java) { + return Unsupported("Expected Int at index $i after Composer, but found ${types[i].simpleName}") + } + } + + return WithComposer(composerIndex, paramCount, types) + } + } +}