Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Update compose invoker to better replicate PreviewActivity behavior
Browse files Browse the repository at this point in the history
rbro112 committed Oct 25, 2024
1 parent c289b47 commit 115ef88
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()
}
}

Original file line number Diff line number Diff line change
@@ -8,64 +8,25 @@ import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.compose.runtime.currentComposer
import androidx.compose.runtime.reflect.ComposableMethod
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.tooling.PreviewActivity
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.IntSize
import com.emergetools.snapshots.EmergeSnapshots
import com.emergetools.snapshots.SnapshotErrorType
import com.emergetools.snapshots.compose.previewparams.PreviewParamUtils
import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig
import java.lang.reflect.Modifier

@Suppress("TooGenericExceptionCaught", "ThrowsCount")
@Suppress("TooGenericExceptionCaught")
fun snapshotComposable(
snapshotRule: EmergeSnapshots,
activity: PreviewActivity,
previewConfig: ComposePreviewSnapshotConfig,
) {
try {
val klass = Class.forName(previewConfig.fullyQualifiedClassName)
Log.d(
EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG,
"Found class for ${previewConfig.fullyQualifiedClassName}: ${klass.name}"
)
val methodName = previewConfig.originalFqn.substringAfterLast(".")

val previewProviderClass: Class<out PreviewParameterProvider<*>>? =
previewConfig.previewParameter?.providerClassFqn?.let {
val clazz = Class.forName(it)
require(PreviewParameterProvider::class.java.isAssignableFrom(clazz)) {
"Preview parameter provider class must implement PreviewParameterProvider"
}
clazz as Class<out PreviewParameterProvider<*>>
}

// Use ComposableInvoker to find and prepare the method
val composableMethod = ComposableInvoker.findComposableMethod(
klass = klass,
methodName = methodName,
previewProviderClass = previewProviderClass
)

// Get preview parameters
val previewParams = ComposableInvoker.getPreviewParameters(
parameterProviderClass = previewProviderClass,
limit = previewConfig.previewParameter?.limit
)

Log.d(
EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG,
"Found ${previewParams.size} preview parameters for ${previewConfig.originalFqn}"
)

snapshot(
activity = activity,
snapshotRule = snapshotRule,
previewConfig = previewConfig,
composableMethod = composableMethod,
composableClass = klass,
previewParams = previewParams,
)
} catch (e: Exception) {
Log.e(
@@ -85,26 +46,26 @@ fun snapshotComposable(
private fun snapshot(
activity: Activity,
snapshotRule: EmergeSnapshots,
composableMethod: ComposableMethod,
composableClass: Class<*>,
previewConfig: ComposePreviewSnapshotConfig,
previewParams: List<Any?> = listOf(null),
) {
previewParams.forEachIndexed { index, prevParam ->
val composeView = ComposeView(activity)
composeView.layoutParams =
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
// If no preview params, fallback to a single array of one null item to ensure we
// snapshot the composable.
val previewParameters =
PreviewParamUtils.getPreviewProviderParameters(previewConfig) ?: arrayOf<Any?>(null)

val deviceSpec = configToDeviceSpec(previewConfig)

for (index in previewParameters.indices) {
val prevParam = previewParameters[index]
Log.d(
EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG,
"Invoking composable method with preview parameter: $prevParam"
)
val args = buildList {
prevParam?.let(this::add)
add(0)
}.toTypedArray()

val deviceSpec = configToDeviceSpec(previewConfig)
val composeView = ComposeView(activity)
composeView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
val args = emptyArray<Any?>().apply {
prevParam?.let(this::plus)
}

val saveablePreviewConfig = previewConfig.copy(
previewParameter = previewConfig.previewParameter?.copy(index = index)
@@ -117,16 +78,12 @@ private fun snapshot(

composeView.setContent {
SnapshotVariantProvider(previewConfig, deviceSpec?.scalingFactor) {
@Suppress("SpreadOperator")
if (Modifier.isStatic(composableMethod.asMethod().modifiers)) {
// This is a top level or static method
composableMethod.invoke(currentComposer, null, *args)
} else {
// The method is part of a class. We try to instantiate the class with an empty
// constructor.
val instance = composableClass.getConstructor().newInstance()
composableMethod.invoke(currentComposer, instance, *args)
}
ComposableInvoker.invokeComposable(
className = previewConfig.fullyQualifiedClassName,
methodName = previewConfig.originalFqn.substringAfterLast("."),
composer = currentComposer,
args = args,
)
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.emergetools.snapshots.compose.previewparams

import android.util.Log
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig

// Inspired by https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/PreviewUtils.android.kt
object PreviewParamUtils {
private const val TAG = "PreviewParamUtils"

internal fun getPreviewProviderParameters(
previewConfig: ComposePreviewSnapshotConfig
): Array<Any?>? {
if (previewConfig.previewParameter == null) {
Log.d(TAG, "No PreviewParameterProvider found")
return null
}

if (previewConfig.previewParameter?.providerClassFqn.isNullOrEmpty()) {
Log.e(TAG, "PreviewParameterProvider class name is empty")
return null
}

val paramProviderClass = try {
Class.forName(previewConfig.previewParameter!!.providerClassFqn) as? Class<out PreviewParameterProvider<*>>
} catch (e: ClassNotFoundException) {
Log.e(
TAG,
"Unable to find PreviewProvider '${previewConfig.previewParameter!!.providerClassFqn}'",
e
)
return null
}

if (paramProviderClass == null) {
Log.e(
TAG,
"PreviewProvider '${previewConfig.previewParameter!!.providerClassFqn}' is not a PreviewParameterProvider"
)
return null
}

val constructor = paramProviderClass.constructors
.singleOrNull { it.parameterTypes.isEmpty() }
?.apply { isAccessible = true }
?: throw IllegalArgumentException(
"PreviewParameterProvider constructor can not" + " have parameters"
)
val params = constructor.newInstance() as PreviewParameterProvider<*>

return Array(params.count) { params.values.iterator().next() }
.map { unwrapIfInline(it) }
.toTypedArray()
}

/**
* Checks if the object is of inlined value type. If yes, unwraps and returns the packed value If
* not, returns the object as it is
*/
private fun unwrapIfInline(classToCheck: Any?): Any? {
// At the moment is not possible to use classToCheck::class.isValue, even if it works when
// running tests, is not working once trying to run the Preview instead.
// it would be possible in the future.
// see also https://kotlinlang.org/docs/inline-classes.html
if (classToCheck != null && classToCheck::class.java.annotations.any { it is JvmInline }) {
// The first primitive declared field in the class is the value wrapped
val fieldName: String =
classToCheck::class.java.declaredFields.first { it.type.isPrimitive }.name
return classToCheck::class
.java
.getDeclaredField(fieldName)
.also { it.isAccessible = true }
.get(classToCheck)
}
return classToCheck
}
}

0 comments on commit 115ef88

Please sign in to comment.