Skip to content

feat(snapshot): Add io.sentry.android.snapshot.metadata plugin#1113

Merged
runningcode merged 8 commits intomainfrom
no/snapshot-metadata-plugin
Mar 26, 2026
Merged

feat(snapshot): Add io.sentry.android.snapshot.metadata plugin#1113
runningcode merged 8 commits intomainfrom
no/snapshot-metadata-plugin

Conversation

@runningcode
Copy link
Copy Markdown
Contributor

@runningcode runningcode commented Mar 19, 2026

Summary

  • Add a new standalone Gradle plugin (io.sentry.android.snapshot.metadata) that exports all Compose @Preview metadata to a JSON file using ASM bytecode scanning at build time
  • Can be applied independently without Paparazzi — only requires com.android.library or com.android.application

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

 "previews": [
        {
            "className": "com.emergetools.hackernews.ui.components.MetadataTag",
            "methodName": "MetadataTagPreview",
            "sourceFileName": "MetadataTag.kt",
            "previewName": "light",
            "configuration": {
                "apiLevel": null,
                "locale": null,
                "fontScale": 1.0,
                "uiMode": 0,
                "showSystemUi": false,
                "showBackground": false,
                "backgroundColor": null,
                "group": null,
                "wallpaper": null
            },
            "device": null,
            "previewParameter": null
        },
        ...
]

#skip-changelog

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 19, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


This PR will not appear in the changelog.


🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 19, 2026

Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 47ee093

runningcode and others added 4 commits March 24, 2026 14:18
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>
@runningcode runningcode force-pushed the no/snapshot-metadata-plugin branch from 770d9a2 to 0ede350 Compare March 24, 2026 14:12
@runningcode runningcode marked this pull request as ready for review March 24, 2026 14:16
Comment on lines +95 to +97
val rawClassName = relativePath.removeSuffix(".class").replace('/', '.').replace('\\', '.')
// Strip Kt suffix — Kotlin top-level functions compile to FooKt.class
val className = rawClassName.removeSuffix("Kt")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

…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) }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@runningcode runningcode merged commit ae7ed20 into main Mar 26, 2026
20 of 21 checks passed
@runningcode runningcode deleted the no/snapshot-metadata-plugin branch March 26, 2026 09:08
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Additional Locations (1)
Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants