Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update compose invoker to better replicate PreviewActivity behavior #296

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,149 +1,210 @@
package com.emergetools.snapshots.compose

import android.util.Log
import androidx.compose.runtime.reflect.ComposableMethod
import androidx.compose.runtime.reflect.getDeclaredComposableMethod
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import java.lang.reflect.ParameterizedType

/**
* Handles reflection-based invocation of Composable methods,
* including support for preview parameters.
*/
import androidx.compose.runtime.Composer
import java.lang.reflect.Method
import java.lang.reflect.Modifier
import kotlin.math.ceil

// Inspired by https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-tooling/src/jvmMain/kotlin/androidx/compose/ui/tooling/ComposableInvoker.jvm.kt
@Suppress("TooGenericExceptionCaught", "SwallowedException", "SpreadOperator")
object ComposableInvoker {
private const val TAG = "ComposableInvoker"

/**
* Finds and returns the appropriate ComposableMethod for the given parameters.
*/
fun findComposableMethod(
klass: Class<*>,
fun invokeComposable(
className: String,
methodName: String,
previewProviderClass: Class<out PreviewParameterProvider<*>>? = null
): ComposableMethod {
val composableMethod = previewProviderClass?.let { previewProvider ->
Log.d(TAG, "Looking for parameterized composable method: $methodName in class: ${klass.name}")

// Find the type argument for PreviewParameterProvider
val providerType = findPreviewParameterType(previewProvider)
?: throw IllegalArgumentException("Unable to determine type argument for PreviewParameterProvider")

// Try to find the method with either the primitive or wrapper type
tryGetComposableMethod(klass, methodName, providerType)
?: throw NoSuchMethodException("Could not find composable method: $methodName")
} ?: run {
Log.d(TAG, "Looking for composable method: $methodName in class: ${klass.name}")
klass.getDeclaredComposableMethod(methodName)
}
return composableMethod.also {
val backingMethod = composableMethod.asMethod()
if (!backingMethod.isAccessible) {
Log.i(TAG, "Marking composable method $methodName as accessible")
backingMethod.isAccessible = true
composer: Composer,
vararg args: Any?
) {
try {
val composableClass = Class.forName(className)
val method = composableClass.findComposableMethod(methodName, *args)
?: throw NoSuchMethodException("Composable $className.$methodName not found")
method.isAccessible = true

if (Modifier.isStatic(method.modifiers)) {
// This is a top level or static method
method.invokeComposableMethod(null, composer, *args)
} else {
// The method is part of a class. We try to instantiate the class with an empty
// constructor.
val instance = composableClass.getConstructor().newInstance()
method.invokeComposableMethod(instance, composer, *args)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to invoke Composable Method '$className.$methodName'")
throw e
}
}

/**
* Gets parameters from a PreviewParameterProvider, respecting the limit if specified.
* Compares the parameter types taken from the composable method and checks if they are all
* compatible with the types taken from the PreviewParameterProvider.
*
* @param composableMethodTypes types of the Composable Method
* @param previewParameterTypes types defined in the PreviewParameterProvider
* @return true if every `composableMethodTypes[n]` are equal or assignable to
* `previewParameterTypes[n]`.
*/
fun getPreviewParameters(
parameterProviderClass: Class<out PreviewParameterProvider<*>>?,
limit: Int? = null
): List<Any?> {
return parameterProviderClass?.let {
val params = getPreviewProviderParameters(it)
limit?.let { maxLimit -> params.take(maxLimit) } ?: params
} ?: listOf(null)
}
private fun areParameterTypesCompatible(
composableMethodTypes: Array<Class<*>>,
previewParameterTypes: Array<Class<*>>
): Boolean =
composableMethodTypes.size == previewParameterTypes.size &&
composableMethodTypes.mapIndexed { index, clazz ->
val composableParameterType = previewParameterTypes[index]
// We can't use [isAssignableFrom] if we have java primitives.
// Java primitives aren't equal to Java classes:
// comparing int with kotlin.Int or java.lang.Integer will return false.
// However, if we convert them both to a KClass they can be compared:
// int and java.lang.Integer will be both converted to Int
// see more:
// https://docs.oracle.com/javase/6/docs/api/java/lang/Class.html#isAssignableFrom(java.lang.Class)
clazz.kotlin == composableParameterType.kotlin ||
clazz.isAssignableFrom(composableParameterType)
}.all { it }

private fun findPreviewParameterType(previewProviderClass: Class<*>): Class<*>? {
// Look through all interfaces implemented by the class
for (genericInterface in previewProviderClass.genericInterfaces) {
if (genericInterface is ParameterizedType &&
genericInterface.rawType == PreviewParameterProvider::class.java
) {
// Handle different types of type arguments
return when (val typeArg = genericInterface.actualTypeArguments.firstOrNull()) {
// Direct class reference
is Class<*> -> typeArg
// Nested generic type (e.g., List<User>)
is ParameterizedType -> typeArg.rawType as? Class<*>
// Other cases (wildcards, type variables, etc.)
else -> null
}
}
}

// Check superclass if the interface isn't found in the current class
val superclass = previewProviderClass.genericSuperclass
if (superclass is ParameterizedType) {
return findPreviewParameterType(superclass.rawType as Class<*>)
}
/**
* Takes the declared methods and accounts for compatible types so the signature does not need
* to exactly match. This allows finding method calls that use subclasses as parameters instead
* of the exact types.
*
* @return the compatible [Method] with the name [methodName]
* @throws NoSuchMethodException if the method is not found
*/
private fun Array<Method>.findCompatibleComposeMethod(
methodName: String,
vararg args: Class<*>
): Method =
firstOrNull {
(methodName == it.name || it.name.startsWith("$methodName-")) &&
// Methods with inlined classes as parameter will have the name mangled
// so we need to check for methodName-xxxx as well
areParameterTypesCompatible(it.parameterTypes, arrayOf(*args))
} ?: throw NoSuchMethodException("$methodName not found")

return null
private inline fun <reified T> T.dup(count: Int): Array<T> {
return (0 until count).map { this }.toTypedArray()
}

@Suppress("SwallowedException")
private fun tryGetComposableMethod(
klass: Class<*>,
/**
* Find the given method by name. If the method has parameters, this function will try to find
* the version that accepts default parameters.
*
* @return null if the composable method is not found. Returns the [Method] otherwise.
*/
private fun Class<*>.findComposableMethod(
methodName: String,
parameterType: Class<*>
): ComposableMethod? {
// Map of primitive types to their Java object wrapper classes
val primitiveToWrapper: Map<Class<*>?, Class<*>?> = mapOf(
Int::class.javaPrimitiveType to Integer::class.java,
Long::class.javaPrimitiveType to Long::class.java,
Double::class.javaPrimitiveType to Double::class.java,
Float::class.javaPrimitiveType to Float::class.java,
Boolean::class.javaPrimitiveType to Boolean::class.java,
Byte::class.javaPrimitiveType to Byte::class.java,
Short::class.javaPrimitiveType to Short::class.java,
Char::class.javaPrimitiveType to Character::class.java
)

// Map of wrapper classes to their primitive types
val wrapperToPrimitive = primitiveToWrapper.entries.associate { (k, v) -> v to k }

vararg previewParamArgs: Any?
): Method? {
val argsArray: Array<Class<out Any>> =
previewParamArgs.mapNotNull { it?.javaClass }.toTypedArray()
return try {
// First try with the original type
klass.getDeclaredComposableMethod(methodName, parameterType)
} catch (e: NoSuchMethodException) {
Log.d(
TAG,
"Method $methodName not found with parameter type: $parameterType, trying primitive/wrapper type"
val changedParamsCount = changedParamCount(argsArray.size, 0)
val changedParams = Int::class.java.dup(changedParamsCount)
declaredMethods.findCompatibleComposeMethod(
methodName,
*argsArray,
Composer::class.java,
*changedParams,
)
// If that fails, try with the corresponding primitive/wrapper type
val alternateType: Class<*>? = when {
parameterType.isPrimitive -> primitiveToWrapper[parameterType]
wrapperToPrimitive.containsKey(parameterType) -> wrapperToPrimitive[parameterType]
else -> null
} catch (e: ReflectiveOperationException) {
try {
Log.d(TAG, "Method $methodName not found in class ${this.simpleName}, trying mangled name")
declaredMethods.find {
// Methods with inlined classes as parameter will have the name mangled
// so we need to check for methodName-xxxx as well
it.name == methodName || it.name.startsWith("$methodName-")
}
} catch (e: ReflectiveOperationException) {
Log.w(TAG, "Method $methodName not found in class ${this.simpleName}")
null
}
}
}

/**
* Calls the method on the given [instance]. If the method accepts default values, this function
* will call it with the correct options set.
*/
@Suppress("MagicNumber")
private fun Method.invokeComposableMethod(
instance: Any?,
composer: Composer,
vararg args: Any?
): Any? {
val composerIndex = parameterTypes.indexOfLast { it == Composer::class.java }
val realParams = composerIndex
val thisParams = if (instance != null) 1 else 0
val changedParams = changedParamCount(realParams, thisParams)
val totalParamsWithoutDefaults =
realParams +
1 + // composer
changedParams
val totalParams = parameterTypes.size
val isDefault = totalParams != totalParamsWithoutDefaults
val defaultParams = if (isDefault) defaultParamCount(realParams) else 0

check(
realParams +
1 + // composer
changedParams +
defaultParams == totalParams
) {
"params don't add up to total params"
}

alternateType?.let {
try {
klass.getDeclaredComposableMethod(methodName, it)
} catch (e: NoSuchMethodException) {
Log.d(
TAG,
"Method $methodName not found with primitive/wrapped type: $parameterType, returning null"
)
null
val changedStartIndex = composerIndex + 1
val defaultStartIndex = changedStartIndex + changedParams

val arguments =
Array(totalParams) { idx ->
when (idx) {
// pass in "empty" value for all real parameters since we will be using
// defaults.
in 0 until realParams ->
args.getOrElse(idx) { parameterTypes[idx].getDefaultValue() }
// the composer is the first synthetic parameter
composerIndex -> composer
// since this is the root we don't need to be anything unique. 0 should suffice.
// changed parameters should be 0 to indicate "uncertain"
in changedStartIndex until defaultStartIndex -> 0
// Default values mask, all parameters set to use defaults
in defaultStartIndex until totalParams -> 0b111111111111111111111
else -> error("Unexpected index")
}
}
return invoke(instance, *arguments)
}

/**
* Returns the default value for the [Class] type. This will be 0 for numeric types, false for
* boolean, '0' for char and null for object references.
*/
private fun Class<*>.getDefaultValue(): Any? =
when (name) {
"int" -> 0
"short" -> 0.toShort()
"byte" -> 0.toByte()
"long" -> 0.toLong()
"double" -> 0.toDouble()
"float" -> 0.toFloat()
"boolean" -> false
"char" -> 0.toChar()
else -> null
}

private const val SLOTS_PER_INT = 10
private const val BITS_PER_INT = 31

private fun changedParamCount(realValueParams: Int, thisParams: Int): Int {
if (realValueParams == 0) return 1
val totalParams = realValueParams + thisParams
return ceil(totalParams.toDouble() / SLOTS_PER_INT.toDouble()).toInt()
}

private fun getPreviewProviderParameters(
parameterProviderClass: Class<out PreviewParameterProvider<*>>
): List<Any?> {
val constructor = parameterProviderClass.constructors
.singleOrNull { it.parameterTypes.isEmpty() }
?.apply { isAccessible = true }
?: throw IllegalArgumentException(
"PreviewParameterProvider constructor can not have parameters"
)
val params = constructor.newInstance() as PreviewParameterProvider<*>
return params.values.toList()
private fun defaultParamCount(realValueParams: Int): Int {
return ceil(realValueParams.toDouble() / BITS_PER_INT.toDouble()).toInt()
}
}
Loading
Loading