Skip to content

Commit

Permalink
Support @PreviewParameter annotated preview params (#271)
Browse files Browse the repository at this point in the history
* [WIP] Support @PreviewParameter annotated preview params

* Minor cleanups and handle limit

* More examples

* Adjust param

* unit tests and lint

* Add some logging

* Index

* Race

* Adjust

* Add preview filter

* Remove log
  • Loading branch information
rbro112 authored Oct 14, 2024
1 parent bf3bb4e commit b5fc654
Show file tree
Hide file tree
Showing 9 changed files with 356 additions and 72 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.emergetools.android.gradle.tasks.snapshots

import com.emergetools.android.gradle.tasks.snapshots.utils.PreviewUtils
import com.emergetools.android.gradle.tasks.base.ArtifactMetadata
import com.emergetools.android.gradle.tasks.snapshots.utils.PreviewUtils
import com.emergetools.android.gradle.util.adb.AdbHelper
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
Expand Down Expand Up @@ -30,11 +30,12 @@ abstract class LocalSnapshots : DefaultTask() {
private val arguments = mutableMapOf<String, String>()

@Option(
option = "class",
description = "A single class, a single method or a comma-separated list of classes"
option = "preview",
description = "A single fully qualified preview method" +
" or a comma-separated list of fully qualified preview methods"
)
fun setClazz(clazz: String) {
arguments["class"] = clazz
fun setPreviews(previewFunctions: String) {
arguments["previews"] = previewFunctions
}

@get:Inject
Expand Down Expand Up @@ -100,11 +101,14 @@ abstract class LocalSnapshots : DefaultTask() {
outputDir = extractedApkDir
)

val previews = arguments["previews"]?.split(",")?.map(String::trim) ?: emptyList()
val composeSnapshots = PreviewUtils.findPreviews(
extractedApkDir,
includePrivatePreviews.getOrElse(true),
previews,
logger
)

logger.info("Found ${composeSnapshots.snapshots.size} Compose Preview snapshots")
val composeSnapshotsJson = File(previewExtractionDir, COMPOSE_SNAPSHOTS_FILENAME)
composeSnapshotsJson.writeText(Json.encodeToString(composeSnapshots))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ data class ComposeSnapshots(
data class ComposePreviewSnapshotConfig(
val originalFqn: String,
val fullyQualifiedClassName: String,
val previewParameter: PreviewParameter? = null,
// Preview annotation params:
val name: String?,
val group: String?,
Expand All @@ -24,4 +25,14 @@ data class ComposePreviewSnapshotConfig(
val backgroundColor: Long?,
val showSystemUi: Boolean?,
val device: String? = null,
val apiLevel: Int? = null,
val wallpaper: Int? = null,
)

@Serializable
data class PreviewParameter(
val parameterName: String,
val providerClassFqn: String,
val limit: Int? = null,
val index: Int? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import org.jf.dexlib2.AccessFlags
import org.jf.dexlib2.DexFileFactory
import org.jf.dexlib2.dexbacked.DexBackedClassDef
import org.jf.dexlib2.dexbacked.DexBackedMethod
import org.jf.dexlib2.dexbacked.value.DexBackedTypeEncodedValue
import org.jf.dexlib2.iface.Annotation
import org.jf.dexlib2.iface.DexFile
import org.jf.dexlib2.iface.value.AnnotationEncodedValue
Expand All @@ -15,6 +16,7 @@ import org.jf.dexlib2.iface.value.LongEncodedValue
import org.jf.dexlib2.iface.value.StringEncodedValue
import org.slf4j.Logger
import java.io.File
import kotlin.math.log

object PreviewUtils {

Expand All @@ -23,6 +25,7 @@ object PreviewUtils {
"Landroidx/compose/ui/tooling/preview/Preview\$Container;"
const val IGNORE_SNAPSHOT_ANNOTATION =
"Lcom/emergetools/snapshots/annotations/IgnoreEmergeSnapshot;"
const val PREVIEW_PARAMETER_ANNOTATION = "Landroidx/compose/ui/tooling/preview/PreviewParameter;"

const val COMPOSER_SIGNATURE = "Landroidx/compose/runtime/Composer;"

Expand All @@ -31,6 +34,7 @@ object PreviewUtils {
fun findPreviews(
extractedApkDirectory: File,
includePrivatePreviews: Boolean,
previewFunctions: List<String>,
logger: Logger,
): ComposeSnapshots {

Expand Down Expand Up @@ -73,32 +77,66 @@ object PreviewUtils {
return@forEach
}

if (method.parameters.size != 2 || method.parameters.map { it.type } != listOf(
COMPOSER_SIGNATURE, "I"
)) {
logger.info(
"Ignoring snapshot for method: $methodKey as it does not have a no-arg signature"
)
if (!hasSupportedMethodParameters(method, logger)) {
logger.info("Ignoring snapshot for method: $methodKey as params are not supported.")
return@forEach
}

val configs = previewAnnotations.flatMap { previewAnnotation ->
composePreviewSnapshotConfigsFromPreviewAnnotation(method, previewAnnotation)
composePreviewSnapshotConfigsFromPreviewAnnotation(method, previewAnnotation, logger)
}

methodsWithConfigs[methodKey] = configs
}
}

val methods = methodsWithConfigs.values.flatten().filter {
previewFunctions.isEmpty() || previewFunctions.contains(it.originalFqn)
}

return ComposeSnapshots(
snapshots = methodsWithConfigs.values.flatten()
snapshots = methods
)
}

private fun classSignatureToFqn(signature: String): String {
return signature.replace('/', '.').substring(1, signature.length - 1)
}

private fun hasSupportedMethodParameters(method: DexBackedMethod, logger: Logger): Boolean {
// Only supporting 0-1 preview params
val methodParamCount = method.parameters.size
if (methodParamCount < 2 || methodParamCount > 3) {
logger.info("Method ${method.name} has $methodParamCount parameters, expected 2 or 3")
return false
}

// Check last 2 params are (Composer, int)
val paramTypes = method.parameters.map { it.type }
if (paramTypes.takeLast(2) != listOf(COMPOSER_SIGNATURE, "I")) {
logger.info("Method ${method.name} has unsupported parameter types: $paramTypes")
return false
}

if (methodParamCount == 3) {
// Check first param annotated with @PreviewParameter
val firstParam = method.parameters[0]
val hasPreviewParameterAnnotation = firstParam.annotations.any { annotation ->
annotation.type == PREVIEW_PARAMETER_ANNOTATION
}

if (!hasPreviewParameterAnnotation) {
logger.info("Method ${method.name} has 3 parameters, but the first one is not annotated with @PreviewParameter")
return false
}

logger.info("Method ${method.name} has 3 parameters, and the first one is annotated with @PreviewParameter!")
}

logger.info("Method ${method.name} has supported parameters!")
return true
}

/**
* Strip Kt synthetic suffix from class name if it exists to ensure FQN matches the source package.
*/
Expand Down Expand Up @@ -149,10 +187,28 @@ object PreviewUtils {
private fun composePreviewSnapshotConfigsFromPreviewAnnotation(
method: DexBackedMethod,
annotation: Annotation,
logger: Logger,
): List<ComposePreviewSnapshotConfig> {
val className = classSignatureToFqn(method.definingClass)
val originalFqn = fqnForPreviewMethod(method)

var previewParameter: PreviewParameter? = null
if (method.parameters.size == 3) {
val firstParam = method.parameters[0]
val paramName = firstParam.name ?: throw IllegalStateException("Preview parameter must have a name")

val previewParamAnnotation = firstParam.annotations.first { it.type == PREVIEW_PARAMETER_ANNOTATION }
val providerClassSignature = previewParamAnnotation.elements.first { it.name == "provider" }.value as DexBackedTypeEncodedValue
val previewParamLimit = previewParamAnnotation.elements.firstOrNull { it.name == "limit" }?.value as? IntEncodedValue

logger.info("Found @PreviewParameter annotation for method ${method.name}, parameter: $paramName, provider: ${providerClassSignature.value}, limit: ${previewParamLimit?.value}")
previewParameter = PreviewParameter(
parameterName = paramName,
providerClassFqn = classSignatureToFqn(providerClassSignature.value),
limit = previewParamLimit?.value,
)
}

return when (annotation.type) {
PREVIEW_ANNOTATION -> listOf(
ComposePreviewSnapshotConfig(
Expand All @@ -169,6 +225,7 @@ object PreviewUtils {
backgroundColor = (annotation.elements.firstOrNull { it.name == "backgroundColor" }?.value as? LongEncodedValue)?.value,
showSystemUi = (annotation.elements.firstOrNull { it.name == "showSystemUi" }?.value as? BooleanEncodedValue)?.value,
device = (annotation.elements.firstOrNull { it.name == "device" }?.value as? StringEncodedValue)?.value,
previewParameter = previewParameter,
)
)

Expand All @@ -190,6 +247,7 @@ object PreviewUtils {
backgroundColor = (preview.elements.firstOrNull { it.name == "backgroundColor" }?.value as? LongEncodedValue)?.value,
showSystemUi = (preview.elements.firstOrNull { it.name == "showSystemUi" }?.value as? BooleanEncodedValue)?.value,
device = (preview.elements.firstOrNull { it.name == "device" }?.value as? StringEncodedValue)?.value,
previewParameter = previewParameter,
)
}.orEmpty()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.emergetools.snapshots.sample.ui

import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.emergetools.snapshots.sample.ui.theme.SnapshotsSampleTheme

data class User(val name: String, val email: String)

@Composable
fun UserRow(user: User) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Profile picture
Surface(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(Color.Cyan),
color = Color.Transparent
) {
// Center content vertically and horizontally
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = user.name.firstOrNull()?.uppercase() ?: "",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
}

Spacer(modifier = Modifier.width(16.dp))

// User details
Column {
Text(text = user.name, style = MaterialTheme.typography.bodyMedium)
Text(text = user.email, style = MaterialTheme.typography.bodySmall, color = Color.Gray)
}
}
}

@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun UserRowPreview(
@PreviewParameter(UserRowPreviewProvider::class) user: User,
) {
SnapshotsSampleTheme {
UserRow(user = user)
}
}

@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun UserRowPreviewWithLimit(
@PreviewParameter(UserRowPreviewProvider::class, limit = 2) user: User,
) {
SnapshotsSampleTheme {
UserRow(user = user)
}
}

class UserRowPreviewProvider : PreviewParameterProvider<User> {
override val values: Sequence<User> = sequenceOf(
User(name = "Ryan", email = "[email protected]"),
User(name = "Trevor", email = "[email protected]"),
User(name = "Josh", email = "[email protected]"),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ data class ComposePreviewSnapshotConfig(
val fullyQualifiedClassName: String,
val originalFqn: String,
val isAppStoreSnapshot: Boolean? = null,
val previewParameter: PreviewParameter? = null,
// Preview annotation params:
val name: String? = null,
val group: String? = null,
Expand Down Expand Up @@ -41,7 +42,8 @@ data class ComposePreviewSnapshotConfig(
showSystemUi == null &&
device == null &&
apiLevel == null &&
wallpaper == null
wallpaper == null &&
previewParameter == null
}

/**
Expand All @@ -65,6 +67,18 @@ data class ComposePreviewSnapshotConfig(
device?.let { append("_dev_$it") }
apiLevel?.let { append("_api_$it") }
wallpaper?.let { append("_wp_$it") }
previewParameter?.let {
append("_param_${it.parameterName}")
it.index?.let { idx -> append("_idx_$idx") }
}
}
}
}

@Serializable
data class PreviewParameter(
val parameterName: String,
val providerClassFqn: String,
val limit: Int? = null,
val index: Int? = null,
)
Loading

0 comments on commit b5fc654

Please sign in to comment.