Skip to content

Commit ae7ed20

Browse files
runningcodeclauderomtsn
authored
feat(snapshot): Add io.sentry.android.snapshot.metadata plugin (#1113)
* feat(snapshot): Add io.sentry.android.snapshot.metadata plugin Add a standalone Gradle plugin that exports Compose @Preview metadata to a JSON file using ASM bytecode scanning. This plugin can be applied independently without Paparazzi — it only requires an Android plugin and compiled Kotlin classes. New plugin components: - PreviewScanner: ASM-based bytecode scanner for @Preview annotations - ExportPreviewMetadataTask: Cacheable task that scans compiled classes and writes preview-metadata.json - SentrySnapshotMetadataPlugin: Plugin entry point (requires com.android.library or com.android.application) - SentrySnapshotMetadataExtension: Configuration extension with includePrivatePreviews and packageTrees properties Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: Apply spotless formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshot): Fix package prefix matching and reduce bytecode parsing Fix matchesPackage to require a path separator after the prefix, preventing com.example from matching com.exampleextended. Combine source file extraction into the existing PreviewClassVisitor to eliminate a redundant bytecode parse per class file. Add tests for JAR scanning, private preview filtering, prefix collisions, and multi-package scanning. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(snapshot): Add MultiPreview, custom annotations, and MergeClasses support Add full multipreview annotation support to the snapshot metadata plugin, bringing it to parity with emerge-android's scanning capabilities: - Two-pass custom annotation discovery with nested annotation support - @Preview.Container for repeatable @Preview annotations - Built-in multipreview expansion (PreviewLightDark, PreviewFontScale, PreviewScreenSizes, PreviewDynamicColors, and 5 Wear OS previews) - @PreviewParameter extraction (provider class, limit, index, param name) - group and wallpaper @Preview fields - MergeClassesTask to explode JARs into a single directory before scanning Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(snapshot): Extract constants to PreviewConstants.kt Move annotation descriptors, device specs, UI mode constants, and builtinMultipreviewConfigs() out of PreviewScanner.kt into a dedicated PreviewConstants.kt file for better organization. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(snapshot): Remove namespace and package filtering Emerge-android doesn't have package filtering — the scope is controlled entirely by what MergeClasses puts into the directory. Remove scanPackages, namespace, packageTrees, matchesPackage, and scannedPackages to match that approach. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(snapshot): Remove MergeClassesTask, scan classpath in-place (#1121) Instead of merging all classes into an intermediate directory, ExportPreviewMetadataTask now scans directories and JARs directly from the classpath. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshot): Fix builtin multipreview metadata to match Compose source - Capitalize PreviewLightDark names to "Light"/"Dark" matching the actual @Preview annotations in Compose - Swap Blue/Green wallpaper values in PreviewDynamicColors to match Wallpapers.GREEN_DOMINATED_EXAMPLE=1 and BLUE_DOMINATED_EXAMPLE=2 - Only register classes with non-empty previewConfigs in FindCustomPreviewClassVisitor to avoid polluting the map Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Roman Zavarnitsyn <rom4ek93@gmail.com>
1 parent ffb727e commit ae7ed20

File tree

9 files changed

+1971
-0
lines changed

9 files changed

+1971
-0
lines changed

plugin-build/build.gradle.kts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,11 @@ gradlePlugin {
161161
id = "io.sentry.android.snapshot"
162162
implementationClass = "io.sentry.android.gradle.snapshot.SentrySnapshotPlugin"
163163
}
164+
register("sentrySnapshotMetadataPlugin") {
165+
id = "io.sentry.android.snapshot.metadata"
166+
implementationClass =
167+
"io.sentry.android.gradle.snapshot.metadata.SentrySnapshotMetadataPlugin"
168+
}
164169
}
165170
}
166171

@@ -196,6 +201,9 @@ distributions {
196201
create("sentrySnapshotPluginMarker") {
197202
contents { from("build${sep}publications${sep}sentrySnapshotPluginPluginMarkerMaven") }
198203
}
204+
create("sentrySnapshotMetadataPluginMarker") {
205+
contents { from("build${sep}publications${sep}sentrySnapshotMetadataPluginPluginMarkerMaven") }
206+
}
199207
}
200208

201209
tasks.named("distZip") {
@@ -250,6 +258,14 @@ tasks.named("sentrySnapshotPluginMarkerDistZip").configure {
250258
dependsOn("generatePomFileForSentrySnapshotPluginPluginMarkerMavenPublication")
251259
}
252260

261+
tasks.named("sentrySnapshotMetadataPluginMarkerDistTar").configure {
262+
dependsOn("generatePomFileForSentrySnapshotMetadataPluginPluginMarkerMavenPublication")
263+
}
264+
265+
tasks.named("sentrySnapshotMetadataPluginMarkerDistZip").configure {
266+
dependsOn("generatePomFileForSentrySnapshotMetadataPluginPluginMarkerMavenPublication")
267+
}
268+
253269
tasks.withType<Test>().configureEach {
254270
testLogging {
255271
events = setOf(TestLogEvent.SKIPPED, TestLogEvent.PASSED, TestLogEvent.FAILED)
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package io.sentry.android.gradle.snapshot.metadata
2+
3+
import com.android.build.gradle.BaseExtension
4+
import groovy.json.JsonOutput
5+
import java.util.zip.ZipInputStream
6+
import org.gradle.api.DefaultTask
7+
import org.gradle.api.Project
8+
import org.gradle.api.artifacts.component.ProjectComponentIdentifier
9+
import org.gradle.api.attributes.Attribute
10+
import org.gradle.api.file.ConfigurableFileCollection
11+
import org.gradle.api.file.RegularFileProperty
12+
import org.gradle.api.provider.Property
13+
import org.gradle.api.tasks.CacheableTask
14+
import org.gradle.api.tasks.Input
15+
import org.gradle.api.tasks.InputFiles
16+
import org.gradle.api.tasks.OutputFile
17+
import org.gradle.api.tasks.PathSensitive
18+
import org.gradle.api.tasks.PathSensitivity
19+
import org.gradle.api.tasks.TaskAction
20+
import org.gradle.api.tasks.TaskProvider
21+
22+
@CacheableTask
23+
abstract class ExportPreviewMetadataTask : DefaultTask() {
24+
25+
init {
26+
description = "Exports Compose @Preview metadata to a JSON file using ASM bytecode scanning"
27+
}
28+
29+
@get:Input abstract val includePrivatePreviews: Property<Boolean>
30+
31+
@get:InputFiles
32+
@get:PathSensitive(PathSensitivity.RELATIVE)
33+
abstract val inputClasspath: ConfigurableFileCollection
34+
35+
@get:OutputFile abstract val outputFile: RegularFileProperty
36+
37+
@TaskAction
38+
fun export() {
39+
val scanner = PreviewMethodScanner(includePrivatePreviews.get())
40+
41+
// First pass: discover custom preview annotations.
42+
// Two iterations handle nested custom annotations where A references B but B was
43+
// processed after A in the first iteration.
44+
val customAnnotations = mutableMapOf<String, CustomPreviewAnnotation>()
45+
repeat(2) {
46+
forEachClassEntry { _, bytes -> scanner.findCustomAnnotations(bytes, customAnnotations) }
47+
}
48+
49+
// Second pass: find preview methods
50+
val previews = mutableListOf<PreviewMetadata>()
51+
forEachClassEntry { relativePath, bytes ->
52+
scanClassFile(bytes, relativePath, scanner, customAnnotations, previews)
53+
}
54+
55+
val export = PreviewMetadataExport(previews = previews)
56+
val json = JsonOutput.prettyPrint(JsonOutput.toJson(export.toMap()))
57+
58+
val outFile = outputFile.get().asFile
59+
outFile.parentFile.mkdirs()
60+
outFile.writeText(json)
61+
62+
logger.lifecycle("Exported ${previews.size} preview(s) to ${outFile.absolutePath}")
63+
}
64+
65+
private fun forEachClassEntry(action: (relativePath: String, bytes: ByteArray) -> Unit) {
66+
inputClasspath.files.forEach { file ->
67+
if (file.isDirectory) {
68+
file
69+
.walkTopDown()
70+
.filter { it.isFile && it.extension == "class" }
71+
.forEach { classFile ->
72+
val relativePath = classFile.relativeTo(file).path
73+
action(relativePath, classFile.readBytes())
74+
}
75+
} else if (file.isFile && (file.name.endsWith(".jar") || file.name.endsWith(".zip"))) {
76+
ZipInputStream(file.inputStream().buffered()).use { zis ->
77+
generateSequence { zis.nextEntry }
78+
.filter { !it.isDirectory && it.name.endsWith(".class") }
79+
.forEach { entry ->
80+
action(entry.name, zis.readBytes())
81+
zis.closeEntry()
82+
}
83+
}
84+
}
85+
}
86+
}
87+
88+
private fun scanClassFile(
89+
bytes: ByteArray,
90+
relativePath: String,
91+
scanner: PreviewMethodScanner,
92+
customAnnotations: Map<String, CustomPreviewAnnotation>,
93+
results: MutableList<PreviewMetadata>,
94+
) {
95+
val scanResult = scanner.fullScan(bytes, customAnnotations)
96+
val methods = scanResult.previewMethods
97+
if (methods.isEmpty()) return
98+
val sourceFileName = scanResult.sourceFile
99+
100+
val rawClassName = relativePath.removeSuffix(".class").replace('/', '.').replace('\\', '.')
101+
// Strip Kt suffix — Kotlin top-level functions compile to FooKt.class
102+
val className = rawClassName.removeSuffix("Kt")
103+
104+
for (method in methods) {
105+
val config = method.config
106+
val device =
107+
if (config.device != null || config.widthDp != null || config.heightDp != null) {
108+
DeviceMetadata(
109+
deviceSpec = config.device,
110+
widthDp = config.widthDp,
111+
heightDp = config.heightDp,
112+
)
113+
} else {
114+
null
115+
}
116+
117+
val previewParam =
118+
method.previewParameter?.let {
119+
PreviewParameterMetadata(
120+
parameterName = it.parameterName,
121+
providerClassFqn = it.providerClassFqn,
122+
limit = it.limit,
123+
index = it.index,
124+
)
125+
}
126+
127+
results.add(
128+
PreviewMetadata(
129+
className = className,
130+
methodName = method.methodName,
131+
sourceFileName = sourceFileName,
132+
previewName = config.name,
133+
configuration =
134+
PreviewConfiguration(
135+
apiLevel = config.apiLevel,
136+
locale = config.locale,
137+
fontScale = config.fontScale,
138+
uiMode = config.uiMode,
139+
showSystemUi = config.showSystemUi,
140+
showBackground = config.showBackground,
141+
backgroundColor = config.backgroundColor,
142+
group = config.group,
143+
wallpaper = config.wallpaper,
144+
),
145+
device = device,
146+
previewParameter = previewParam,
147+
)
148+
)
149+
}
150+
}
151+
152+
companion object {
153+
private const val ARTIFACT_TYPE = "artifactType"
154+
155+
fun register(
156+
project: Project,
157+
extension: SentrySnapshotMetadataExtension,
158+
android: BaseExtension,
159+
): TaskProvider<ExportPreviewMetadataTask> {
160+
return project.tasks.register(
161+
"exportPreviewMetadata",
162+
ExportPreviewMetadataTask::class.java,
163+
) { task ->
164+
task.includePrivatePreviews.set(extension.includePrivatePreviews)
165+
166+
// Local compiled classes
167+
// TODO Debug is hard coded here. we should allow different variants
168+
task.inputClasspath.from(project.tasks.named("compileDebugKotlin").map { it.outputs.files })
169+
170+
// Dependency project classes
171+
val debugClasspath = project.configurations.getByName("debugRuntimeClasspath")
172+
task.inputClasspath.from(
173+
debugClasspath.incoming
174+
.artifactView { view ->
175+
view.componentFilter { id -> id is ProjectComponentIdentifier }
176+
view.attributes { attrs ->
177+
attrs.attribute(Attribute.of(ARTIFACT_TYPE, String::class.java), "android-classes")
178+
}
179+
}
180+
.files
181+
)
182+
183+
task.outputFile.set(
184+
project.layout.buildDirectory.file(
185+
"sentry-snapshots/preview-metadata/preview-metadata.json"
186+
)
187+
)
188+
189+
task.dependsOn("compileDebugKotlin")
190+
}
191+
}
192+
}
193+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package io.sentry.android.gradle.snapshot.metadata
2+
3+
// region Annotation descriptors
4+
5+
internal const val PREVIEW_DESCRIPTOR = "Landroidx/compose/ui/tooling/preview/Preview;"
6+
internal const val PREVIEW_CONTAINER_DESCRIPTOR =
7+
"Landroidx/compose/ui/tooling/preview/Preview\$Container;"
8+
internal const val PREVIEW_PARAMETER_DESCRIPTOR =
9+
"Landroidx/compose/ui/tooling/preview/PreviewParameter;"
10+
11+
private const val PREVIEW_LIGHT_DARK = "Landroidx/compose/ui/tooling/preview/PreviewLightDark;"
12+
private const val PREVIEW_FONT_SCALE = "Landroidx/compose/ui/tooling/preview/PreviewFontScale;"
13+
private const val PREVIEW_SCREEN_SIZES = "Landroidx/compose/ui/tooling/preview/PreviewScreenSizes;"
14+
private const val PREVIEW_DYNAMIC_COLORS =
15+
"Landroidx/compose/ui/tooling/preview/PreviewDynamicColors;"
16+
17+
private const val WEAR_PREVIEW_SMALL_ROUND =
18+
"Landroidx/wear/compose/ui/tooling/preview/WearPreviewSmallRound;"
19+
private const val WEAR_PREVIEW_LARGE_ROUND =
20+
"Landroidx/wear/compose/ui/tooling/preview/WearPreviewLargeRound;"
21+
private const val WEAR_PREVIEW_SQUARE =
22+
"Landroidx/wear/compose/ui/tooling/preview/WearPreviewSquare;"
23+
private const val WEAR_PREVIEW_FONT_SCALES =
24+
"Landroidx/wear/compose/ui/tooling/preview/WearPreviewFontScales;"
25+
private const val WEAR_PREVIEW_DEVICES =
26+
"Landroidx/wear/compose/ui/tooling/preview/WearPreviewDevices;"
27+
28+
// endregion
29+
30+
// region Device spec constants
31+
// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-tooling-preview/src/androidMain/kotlin/androidx/compose/ui/tooling/preview/Device.android.kt
32+
33+
private const val PHONE =
34+
"spec:id=reference_phone,shape=Normal,width=411,height=891,unit=dp,dpi=420"
35+
private const val FOLDABLE =
36+
"spec:id=reference_foldable,shape=Normal,width=673,height=841,unit=dp,dpi=420"
37+
private const val TABLET =
38+
"spec:id=reference_tablet,shape=Normal,width=1280,height=800,unit=dp,dpi=240"
39+
private const val DESKTOP =
40+
"spec:id=reference_desktop,shape=Normal,width=1920,height=1080,unit=dp,dpi=160"
41+
private const val SMALL_ROUND = "id:wearos_small_round"
42+
private const val LARGE_ROUND = "id:wearos_large_round"
43+
private const val SQUARE = "id:wearos_square"
44+
45+
// endregion
46+
47+
// region UI mode constants
48+
// https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/content/res/Configuration.java
49+
50+
private const val UI_MODE_NIGHT_YES: Int = 32
51+
private const val UI_MODE_TYPE_NORMAL: Int = 1
52+
53+
// endregion
54+
55+
// region Built-in multipreview expansion
56+
57+
@Suppress("CyclomaticComplexMethod", "LongMethod")
58+
internal fun builtinMultipreviewConfigs(descriptor: String?): List<PreviewConfig>? {
59+
return when (descriptor) {
60+
PREVIEW_LIGHT_DARK ->
61+
listOf(
62+
PreviewConfig(name = "Light"),
63+
PreviewConfig(name = "Dark", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL),
64+
)
65+
66+
PREVIEW_FONT_SCALE ->
67+
listOf(
68+
PreviewConfig(name = "85%", fontScale = 0.85f),
69+
PreviewConfig(name = "100%", fontScale = 1.0f),
70+
PreviewConfig(name = "115%", fontScale = 1.15f),
71+
PreviewConfig(name = "130%", fontScale = 1.3f),
72+
PreviewConfig(name = "150%", fontScale = 1.5f),
73+
PreviewConfig(name = "180%", fontScale = 1.8f),
74+
PreviewConfig(name = "200%", fontScale = 2f),
75+
)
76+
77+
PREVIEW_SCREEN_SIZES ->
78+
listOf(
79+
PreviewConfig(name = "Phone", device = PHONE, showSystemUi = true),
80+
PreviewConfig(
81+
name = "Phone - Landscape",
82+
device = "spec:width = 411dp, height = 891dp, orientation = landscape, dpi = 420",
83+
showSystemUi = true,
84+
),
85+
PreviewConfig(name = "Unfolded Foldable", device = FOLDABLE, showSystemUi = true),
86+
PreviewConfig(name = "Tablet", device = TABLET, showSystemUi = true),
87+
PreviewConfig(name = "Desktop", device = DESKTOP, showSystemUi = true),
88+
)
89+
90+
PREVIEW_DYNAMIC_COLORS ->
91+
listOf(
92+
PreviewConfig(name = "Red", wallpaper = 0),
93+
PreviewConfig(name = "Green", wallpaper = 1),
94+
PreviewConfig(name = "Blue", wallpaper = 2),
95+
PreviewConfig(name = "Yellow", wallpaper = 3),
96+
)
97+
98+
WEAR_PREVIEW_SMALL_ROUND ->
99+
listOf(
100+
PreviewConfig(
101+
device = SMALL_ROUND,
102+
backgroundColor = 0xff000000,
103+
showBackground = true,
104+
group = "Devices - Small Round",
105+
showSystemUi = true,
106+
)
107+
)
108+
109+
WEAR_PREVIEW_LARGE_ROUND ->
110+
listOf(
111+
PreviewConfig(
112+
device = LARGE_ROUND,
113+
backgroundColor = 0xff000000,
114+
showBackground = true,
115+
group = "Devices - Large Round",
116+
showSystemUi = true,
117+
)
118+
)
119+
120+
WEAR_PREVIEW_SQUARE ->
121+
listOf(
122+
PreviewConfig(
123+
device = SQUARE,
124+
backgroundColor = 0xff000000,
125+
showBackground = true,
126+
group = "Devices - Square",
127+
showSystemUi = true,
128+
)
129+
)
130+
131+
WEAR_PREVIEW_FONT_SCALES -> {
132+
val base =
133+
PreviewConfig(
134+
device = SMALL_ROUND,
135+
showSystemUi = true,
136+
backgroundColor = 0xff000000,
137+
showBackground = true,
138+
)
139+
listOf(
140+
base.copy(group = "Fonts - Small", fontScale = 0.94f),
141+
base.copy(group = "Fonts - Normal", fontScale = 1f),
142+
base.copy(group = "Fonts - Medium", fontScale = 1.06f),
143+
base.copy(group = "Fonts - Large", fontScale = 1.12f),
144+
base.copy(group = "Fonts - Larger", fontScale = 1.18f),
145+
base.copy(group = "Fonts - Largest", fontScale = 1.24f),
146+
)
147+
}
148+
149+
WEAR_PREVIEW_DEVICES -> {
150+
val base =
151+
PreviewConfig(showSystemUi = true, backgroundColor = 0xff000000, showBackground = true)
152+
listOf(
153+
base.copy(device = SMALL_ROUND, group = "Devices - Small Round"),
154+
base.copy(device = LARGE_ROUND, group = "Devices - Large Round"),
155+
base.copy(device = SQUARE, group = "Devices - Small Square"),
156+
)
157+
}
158+
159+
else -> null
160+
}
161+
}
162+
163+
// endregion

0 commit comments

Comments
 (0)