Skip to content

Commit

Permalink
Update compose invoker to better replicate PreviewActivity behavior
Browse files Browse the repository at this point in the history
  • Loading branch information
rbro112 committed Oct 25, 2024
1 parent c576c30 commit 2fd84fe
Show file tree
Hide file tree
Showing 3 changed files with 277 additions and 184 deletions.
Original file line number Diff line number Diff line change
@@ -1,149 +1,208 @@
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
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"
// without defaults
val changedParamsCount = changedParamCount(argsArray.size, 0)
val changedParams = Int::class.java.dup(changedParamsCount)
declaredMethods.findCompatibleComposeMethod(
methodName,
*argsArray,
Composer::class.java, // composer param
*changedParams // changed param
)
// 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 {
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) {
null
}
}
}

/**
* Calls the method on the given [instance]. If the method accepts default values, this function
* will call it with the correct options set.
*/
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"
}

val changedStartIndex = composerIndex + 1
val defaultStartIndex = changedStartIndex + changedParams

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

0 comments on commit 2fd84fe

Please sign in to comment.