Repository navigation
fix(replay): mask Compose images that have no content description - #898
Open
posthog[bot] wants to merge 4 commits into
Open
posthog[bot] wants to merge 4 commits into
posthog[bot] wants to merge 4 commits into
Conversation
maskAllImages found Compose images only through the ContentDescription semantics property. Image and Coil AsyncImage with contentDescription = null add no semantics, so session replay recorded them unmasked. The Compose mask pass now also walks the layout tree and masks each node whose modifier holds a non-vector, non-color Painter. postHogUnmask still skips the subtree. Consumer R8 rules keep the LayoutNode children accessor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Generated-By: PostHog Desktop Task-Id: 548ab6c2-2e92-481f-bc28-098f88bef3e7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Generated-By: PostHog Desktop Task-Id: 548ab6c2-2e92-481f-bc28-098f88bef3e7
…mages Adds the Image(bitmap = ..., contentDescription = null) case from review, plus the described-image, masking-off and postHogMask controls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coil 3's AsyncImage and Glide keep the painter on the Modifier.Node, not on the modifier element, so a content-description-less AsyncImage was still recorded unmasked. Scan each modifier and its paired node, and mask at that modifier's coordinates so a painter followed by padding covers the padding too. Fail closed when Modifier.Node exists but its accessors moved, and keep the BrushPainter name under R8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
💡 Motivation and Context
maskAllImages. Images with no content description show unmasked in session recordings. This is a silent privacy-masking failure.findMaskableComposeWidgetsfinds an image only throughSemanticsProperties.ContentDescription.Imageand CoilAsyncImagewithcontentDescription = nulladd no semantics, so the semantics walk never sees them. Icons usually have a description, so they get masked and the gap is easy to miss.maskAllImagesis on, the Compose mask pass also walks the layout tree and masks each node whose modifier holds aPainter. This findsModifier.paint(Image,Icon) and Coil's content painter element, with or without semantics.contentDescription = nullVectorPainter/ColorPainter/BrushPainter, no descriptionVectorDrawable/ColorDrawablefor Views)postHogUnmask()LayoutNode.childrenis internal, and its JVM name changed between Compose versions (getChildren$ui_releasein older builds,getChildren$uiin Compose 1.11). The code tries the known names and thengetChildrenInfo. New consumer R8 rules keep these methods.Warning
The layout walk runs on the main thread for each Compose mask pass, one
getModifierInfo()call per layout node. I did not benchmark it on a large screen.Out of scope: wireframe mode for Compose.
View.toWireframewalks Android Views only. That is a separate follow-up.💚 How did you test it?
PostHogReplayComposeImageMaskTest, with real Compose content:contentDescription = nullgets exactly one mask rect at its bounds. With the new call disabled, this test fails (expected:<[Rect(0, 56 - 40, 96)]> but was:<[]>).postHogUnmask(), andmaskAllImages = falseall get no rect../gradlew :posthog-android:testDebugUnitTest --tests "com.posthog.android.replay.*" --tests "com.posthog.android.internal.Interaction*": 407 tests, 0 failures../gradlew spotlessCheck :posthog-android:apiCheck: pass. No public API change.AsyncImage(contentDescription = null)screen in a minified build before release.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Fully autonomous
Role.Image.ImageaddsRole.Imageonly when a description exists, so it gives no new coverage.PainterElement. R8 renames these classes in minified apps.getChildren$ui_release. The test failed on Compose 1.11, which usesgetChildren$ui. This is why the code tries several names.Created with PostHog Desktop from this inbox report.
🤖 Generated with Claude Code
Origin
Follow-up after review
Addresses the #908 review:
AsyncImagewas still unmasked. Its modifier element holds noPainter(only the request and loader), and the painter lives on theModifier.Node. Glide is the same. The pass now checks each modifier and its paired node, walkingLayoutNode.nodes→NodeChain.head→Modifier.Node.childthe same wayNodeChain.getModifierInfodoes. Verified with a temporary test against real Coil 3.3.0: unmasked before, masked after.DrawModifiers holding a painter are still found, on Compose 1.3.x and on Coil 2.0–2.5.Modifier.paint(photo).padding(30.dp)covers the padding band too.Modifier.Nodeexists but the accessors moved, or the node chain is shorter than the modifiers, the frame is discarded. Compose before 1.3 has noModifier.Nodeand only scans elements.Modifier$Node/BrushPainternames.Image(bitmap = …)with no description.postHogMask()controls.Not done: painters held by
DelegatingNodedelegates (no library found that does this). The node scan also makes the per-capture main-thread pass about 3x slower on the JVM (1.7 ms vs 0.5 ms for 1,200 layout nodes). That hasn't been measured on a device.