Skip to content

fix(replay): mask Compose images that have no content description - #898

Open
posthog[bot] wants to merge 4 commits into
mainfrom
posthog-self-driving/fixreplay-mask-compose-images-without-a-6d213d
Open

posthog[bot] wants to merge 4 commits into
mainfrom
posthog-self-driving/fixreplay-mask-compose-images-without-a-6d213d

Conversation

@posthog

@posthog posthog Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

  • Who is hurt: every Jetpack Compose app that relies on maskAllImages. Images with no content description show unmasked in session recordings. This is a silent privacy-masking failure.
  • Cause: findMaskableComposeWidgets finds an image only through SemanticsProperties.ContentDescription. Image and Coil AsyncImage with contentDescription = null add 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.
  • Fix mechanism: when maskAllImages is on, the Compose mask pass also walks the layout tree and masks each node whose modifier holds a Painter. This finds Modifier.paint (Image, Icon) and Coil's content painter element, with or without semantics.
Painter Before After
Bitmap or Coil painter, with description masked masked
Bitmap or Coil painter, contentDescription = null not masked masked
VectorPainter / ColorPainter / BrushPainter, no description not masked not masked (same rule as VectorDrawable / ColorDrawable for Views)
Any image under postHogUnmask() not masked not masked
  • The painter is found by the type of a modifier field, not by class name, so R8 renaming does not break it.
  • LayoutNode.children is internal, and its JVM name changed between Compose versions (getChildren$ui_release in older builds, getChildren$ui in Compose 1.11). The code tries the known names and then getChildrenInfo. New consumer R8 rules keep these methods.
  • If no accessor exists, the pass throws. The existing catch marks the walk as failed, and the frame is discarded (fail closed). This matches the current policy for a Compose API mismatch.

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.toWireframe walks Android Views only. That is a separate follow-up.

💚 How did you test it?

  • New Robolectric test PostHogReplayComposeImageMaskTest, with real Compose content:
    • a painter image with contentDescription = null gets exactly one mask rect at its bounds. With the new call disabled, this test fails (expected:<[Rect(0, 56 - 40, 96)]> but was:<[]>).
    • a vector image, an image under postHogUnmask(), and maskAllImages = false all 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.
  • Not done: a release (R8) build of a sample app, and a manual check of a real recording on a device. Please check one Coil AsyncImage(contentDescription = null) screen in a minified build before release.

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Fully autonomous

  • Written by Claude Code (PostHog Desktop) from a PostHog inbox report.
  • Rejected: checking Role.Image. Image adds Role.Image only when a description exists, so it gives no new coverage.
  • Rejected: matching modifier class names such as PainterElement. R8 renames these classes in minified apps.
  • The first draft used only getChildren$ui_release. The test failed on Compose 1.11, which uses getChildren$ui. This is why the code tries several names.

Created with PostHog Desktop from this inbox report.

🤖 Generated with Claude Code

Origin

  • Conversations: ticket
  • First signal: 2026-10-28
  • Inbox report: open
  • Task started by: auto-start, after the report was rated P1 and ready to fix

Follow-up after review

Addresses the #908 review:

  • Coil 3 AsyncImage was still unmasked. Its modifier element holds no Painter (only the request and loader), and the painter lives on the Modifier.Node. Glide is the same. The pass now checks each modifier and its paired node, walking LayoutNode.nodes → NodeChain.head → Modifier.Node.child the same way NodeChain.getModifierInfo does. Verified with a temporary test against real Coil 3.3.0: unmasked before, masked after.
  • Old-style DrawModifiers holding a painter are still found, on Compose 1.3.x and on Coil 2.0–2.5.
  • Each match is masked at its own modifier's coordinates, so Modifier.paint(photo).padding(30.dp) covers the padding band too.
  • Fail closed: if Modifier.Node exists but the accessors moved, or the node chain is shorter than the modifiers, the frame is discarded. Compose before 1.3 has no Modifier.Node and only scans elements.
  • R8: new consumer rules keep the node-chain accessors and the Modifier$Node / BrushPainter names.
  • New tests:
    • Image(bitmap = …) with no description.
    • The described-image, masking-off and postHogMask() controls.
    • A painter only on the node.
    • An old-style draw modifier.
    • Paint followed by padding.
  • Accessor names checked with javap on Compose ui 1.0.1, 1.3.3, 1.4.3, 1.7.0, 1.11.4 and 1.12.1.

Not done: painters held by DelegatingNode delegates (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.

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
@posthog posthog Bot added the self-driving label Oct 28, 2026
@posthog
posthog Bot requested a review from marandaneto October 28, 2026 12:45
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 548ab6c2-2e92-481f-bc28-098f88bef3e7
@turnipdabeets
turnipdabeets marked this pull request as ready for review October 29, 2026 21:24
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 29, 2026 21:24
@turnipdabeets turnipdabeets self-assigned this Oct 29, 2026
turnipdabeets and others added 2 commits October 29, 2026 17:33
…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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant