feat(snapshot): Add io.sentry.android.snapshot.metadata plugin#1113
feat(snapshot): Add io.sentry.android.snapshot.metadata plugin#1113runningcode merged 8 commits intomainfrom
Conversation
Semver Impact of This PR🟡 Minor (new features) 📋 Changelog PreviewThis is how your changes will appear in the changelog. This PR will not appear in the changelog. 🤖 This preview updates automatically when you update the PR. |
|
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
…s 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>
770d9a2 to
0ede350
Compare
| val rawClassName = relativePath.removeSuffix(".class").replace('/', '.').replace('\\', '.') | ||
| // Strip Kt suffix — Kotlin top-level functions compile to FooKt.class | ||
| val className = rawClassName.removeSuffix("Kt") |
There was a problem hiding this comment.
Bug: The code unconditionally strips the "Kt" suffix from all class names, which can corrupt the name of a regular class that legitimately ends with "Kt".
Severity: HIGH
Suggested Fix
Instead of unconditionally stripping the "Kt" suffix, inspect the class's bytecode for the @Metadata annotation. Check the metadata kind field (k) to reliably distinguish between a file facade (where k=2) and a regular class (where k=1). Only remove the "Kt" suffix if the class is identified as a file facade.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location:
plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/metadata/ExportPreviewMetadataTask.kt#L95-L97
Potential issue: The logic to determine the class name unconditionally strips the "Kt"
suffix from any class name ending with it. This is intended to handle Kotlin file
facades (e.g., `GreetingKt.class` for `Greeting.kt`), but it incorrectly alters the
names of regular classes that happen to end in "Kt" (e.g., `class NetworkKt`). This
results in an incorrect `className` in the exported metadata, causing downstream
consumers that rely on this name to fail silently when trying to locate and render
composable previews.
Did we get this right? 👍 / 👎 to inform future reviews.
| PreviewConfig(name = "Unfolded Foldable", device = FOLDABLE, showSystemUi = true), | ||
| PreviewConfig(name = "Tablet", device = TABLET, showSystemUi = true), | ||
| PreviewConfig(name = "Desktop", device = DESKTOP, showSystemUi = true), | ||
| ) |
There was a problem hiding this comment.
PreviewScreenSizes missing Tablet Landscape configuration
Medium Severity
The PREVIEW_SCREEN_SIZES expansion only produces 5 configurations, but the actual @PreviewScreenSizes annotation defines 6 — the "Tablet - Landscape" configuration is missing. Methods annotated with @PreviewScreenSizes will have incomplete metadata, missing the landscape tablet preview variant.
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>
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>
|
|
||
| // Inherit configs from already-discovered custom annotations | ||
| if (descriptor != null && customAnnotations.containsKey(descriptor)) { | ||
| current.previewConfigs.addAll(customAnnotations[descriptor]!!.previewConfigs) |
There was a problem hiding this comment.
Inherited custom configs not defensively copied
Low Severity
In FindCustomPreviewClassVisitor, configs inherited from custom annotations are added by reference (addAll without .map { it.copy() }), whereas PreviewMethodVisitor correctly copies them with .map { it.copy() }. Since PreviewConfig uses mutable var fields, this creates shared mutable state across entries in the customAnnotations map—an inconsistency that could lead to subtle mutation bugs if the code evolves.
Additional Locations (1)
…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>
| val customAnnotations = mutableMapOf<String, CustomPreviewAnnotation>() | ||
| repeat(2) { | ||
| forEachClassEntry { _, bytes -> scanner.findCustomAnnotations(bytes, customAnnotations) } | ||
| } |
There was a problem hiding this comment.
Custom annotation nesting fails beyond one level deep
Low Severity
repeat(2) only resolves one level of custom annotation indirection under worst-case file ordering. For example, @Level2 → @Level1 → @Preview: if classes are visited in order Level2, Level1, Base, the second iteration still sees Level1 with zero configs when processing Level2. An iterate-until-stable loop would handle arbitrary nesting depth correctly.
There was a problem hiding this comment.
Yup. this is a known bug. not worth adding the extra complexity until it becomes an issue with a customer.
- 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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| fun register( | ||
| project: Project, | ||
| extension: SentrySnapshotMetadataExtension, | ||
| android: BaseExtension, |
There was a problem hiding this comment.
Unused android parameter in task registration method
Low Severity
The android: BaseExtension parameter in ExportPreviewMetadataTask.register() is declared and passed by the caller in SentrySnapshotMetadataPlugin.wireWithAndroid() but never referenced inside the method body. This means project.extensions.getByType(BaseExtension::class.java) in the plugin is also unnecessary work. The parameter likely was intended for future variant-awareness (matching the TODO about hardcoded debug), but currently it's dead code.


Summary
io.sentry.android.snapshot.metadata) that exports all Compose@Previewmetadata to a JSON file using ASM bytecode scanning at build timecom.android.libraryorcom.android.applicationThis works using two passes with ASM, very similar to the emerge-android approach.
Here is a sample of the generated json file, we can also filter out the nulls if that is better:
#skip-changelog
🤖 Generated with Claude Code