Skip to content

feat(android): compose screenshot windows in session replay - #823

Draft
dustinbyrne wants to merge 4 commits into
mainfrom
feat/replay-window-composition-prototype
Draft

dustinbyrne wants to merge 4 commits into
mainfrom
feat/replay-window-composition-prototype

Conversation

@dustinbyrne

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Screenshot-mode Android replay currently records the Activity and its dialog in separate window snapshots, so a bottom sheet can play over a blank canvas instead of the checkout beneath it. This draft composes attached Activity and dialog layers into one scene, retaining transparent pixels and the dim layer, and sends complete snapshots under the session window ID. It includes a debug-only synthetic checkout and window probes for reproduction.

Shared replay comparison

Both recordings show the synthetic Northstar Market checkout and delivery-options bottom sheet:

Before this can be merged

  • Privacy blocker on API 26: window-surface insets can shift PixelCopy content relative to decor-coordinate masks. In an API 26 fixture, tagged synthetic dialog text remained visible outside its mask. Establish a validated mask/capture mapping on affected older APIs or gate overlays that cannot be safely aligned; verify the complete SDK image. Do not release this prototype as-is.
  • Finish the production replay-player normalization and remaining bridge routing. The separate scratch player tests are not included in this Android PR.
  • Expand attached-window regressions, transitions and effects coverage, and measure attributable peak memory/payload on constrained devices. Reconcile recording-lifecycle overlap with fix(replay): make an explicit stop win over a racing automatic restart #817.

💚 How did you test it?

  • :posthog-android:testDebugUnitTest --tests 'com.posthog.android.replay.PostHogReplayIntegrationTest': 127 passing tests, including differing Activity/dialog window-token ownership.
  • :posthog-samples:posthog-android-sample:assembleDebug, spotlessKotlinCheck, and spotlessKotlinGradleCheck passed.
  • API 26 and API 36 emulator recordings were processed by current replay-player master in scratch tests and inspected with Chromium playback; API 26 uncovered the blocker above. The shared API 36 recordings were produced via actual SDK initialization and session-replay events.

📝 Checklist

  • 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: Human-driven (agent-assisted)

Pi coding agent assisted with Kotlin changes, emulator fixtures, Gradle checks, mock ingest, and Chromium rrweb playback. The session transcript is not linked because it contains a test-project credential. This draft deliberately keeps screenshot-mode composition in the Android SDK and leaves player production work and the older-API masking fix for follow-up; human review is required before merge.

@dustinbyrne dustinbyrne self-assigned this Sep 29, 2026
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Retrigger

[Medium risk] Adds window composition logic to session replay screenshots.

This PR is not safe to merge until the dialog privacy-mask alignment and unthrottled layout capture path are addressed.

Reviews (1) · Last reviewed commit: "feat(android): prototype screenshot-mode..."

) {
cached
} else {
layer.view.toScreenshotWireframe(layer.window, layer.status.drawState, forceAlpha = true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Dialog masks can miss text

On API 26, PixelCopy can capture a dialog with a surface offset from its decor coordinates. This path paints masks using decor coordinates without validating that offset; the reported API 26 fixture leaves tagged dialog text visible outside its mask in the replay image. Align and validate the masks against the captured pixels, or omit overlays that cannot be aligned safely.

How this was verified: Dialog layers reach PixelCopy while their masks are painted from decor coordinates without a surface-origin adjustment.

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt
Line: 568

Comment:
**Dialog masks can miss text**

On API 26, PixelCopy can capture a dialog with a surface offset from its decor coordinates. This path paints masks using decor coordinates without validating that offset; the reported API 26 fixture leaves tagged dialog text visible outside its mask in the replay image. Align and validate the masks against the captured pixels, or omit overlays that cannot be aligned safely.

**How this was verified:** Dialog layers reach PixelCopy while their masks are painted from decor coordinates without a surface-origin adjustment.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +393 to +397
ViewTreeObserver.OnGlobalLayoutListener {
drawState.recordLayout()
if (isActive() && isNativeSdk && config.sessionReplayConfig.screenshot) {
requestSceneCapture(decorView)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Layout captures bypass throttling

On a screen that repeatedly lays out, this listener requests scene captures without the draw listener's throttle. It can drive repeated full-window PixelCopy and encoding work, increasing device load and replay payload. Coalesce or throttle layout-triggered captures with draw-triggered captures.

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt
Line: 393-397

Comment:
**Layout captures bypass throttling**

On a screen that repeatedly lays out, this listener requests scene captures without the draw listener's throttle. It can drive repeated full-window PixelCopy and encoding work, increasing device load and replay payload. Coalesce or throttle layout-triggered captures with draw-triggered captures.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +527 to +538
val attributes = window.attributes
layers.add(
SceneLayer(
view,
window,
status,
position[0],
position[1],
view.width,
view.height,
if (attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND != 0) attributes.dimAmount else 0f,
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Window opacity is ignored

Composition reads the dialog's dim amount but not its window alpha. For a translucent dialog, such as the added proof scene with alpha set to 0.5, the replay draws the captured layer without that opacity and no longer matches what the user saw. Carry window opacity into the composed layer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt
Line: 527-538

Comment:
**Window opacity is ignored**

Composition reads the dialog's dim amount but not its window alpha. For a translucent dialog, such as the added proof scene with alpha set to 0.5, the replay draws the captured layer without that opacity and no longer matches what the user saw. Carry window opacity into the composed layer.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines 319 to +335

@Test
fun `dialog belongs to its activity scene even when window tokens differ`() {
val first = Robolectric.buildActivity(Activity::class.java).setup()
val second = Robolectric.buildActivity(Activity::class.java).setup()
try {
val sut = getSut()
val baseToken = Binder()
val dialogParams = WindowManager.LayoutParams().apply { token = Binder() }
assertTrue(sut.isSceneWindow(baseToken, first.get(), dialogParams, Dialog(first.get()).window!!))
assertFalse(sut.isSceneWindow(baseToken, first.get(), dialogParams, Dialog(second.get()).window!!))
assertFalse(sut.isSceneWindow(baseToken, null, dialogParams, Dialog(first.get()).window!!))
dialogParams.token = baseToken
assertTrue(sut.isSceneWindow(baseToken, null, dialogParams, Dialog(second.get()).window!!))
} finally {
second.pause().stop().destroy()
first.pause().stop().destroy()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Scene output lacks regression coverage

This test checks dialog ownership but never captures a scene or checks its emitted snapshot. Layer order, dialog removal, and masked pixels can therefore regress while it stays green. Add a scene-level test for those behaviors.

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt
Line: 319-335

Comment:
**Scene output lacks regression coverage**

This test checks dialog ownership but never captures a scene or checks its emitted snapshot. Layer order, dialog removal, and masked pixels can therefore regress while it stays green. Add a scene-level test for those behaviors.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Automated advisory code review. The blocking layout-throttling issue is already covered by an existing inline comment; posting the remaining independently reproduced blocking finding.

return false
}
val result = mutableListOf<RREvent>()
if (sceneViewport != viewport) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

blocking: Emit metadata when the base activity changes — Metadata is now emitted only when viewport dimensions change. Switching between same-sized activities updates the screenshot but leaves the previous activity's href in replay; the previous per-decor sentMetaEvent state emitted metadata for each new activity. Track the base activity/title as well as viewport dimensions when deciding whether to emit RRMetaEvent. Reproduction: reproduced — ./gradlew :posthog-android:testDebugUnitTest --tests 'com.posthog.android.replay.PostHogReplayIntegrationTest.*review *' fails on the reviewed head because the snapshot after CheckoutActivity contains no metadata for ReceiptActivity; the equivalent merge-base producer test passes.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants