Skip to content

fix(android): mask session replay frames on FLAG_SECURE windows - #685

Merged
turnipdabeets merged 5 commits into
mainfrom
fix/android-replay-flag-secure
Oct 29, 2026
Merged

turnipdabeets merged 5 commits into
mainfrom
fix/android-replay-flag-secure

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Apps set FLAG_SECURE on screens that must never be screenshotted (login, payment, PIN). Flutter Android session replay ignored it and recorded those screens anyway. Flutter renders replay frames itself, so the fix in posthog-android doesn't cover these frames.

Now, while the activity window has FLAG_SECURE, the frame is still sent but painted fully black at the same size. That's the same colour Flutter replay uses for masks, and it's the same decision as the posthog-android fix: mask rather than skip, so the replay shows that something private was on screen.

  • The frame is masked if the window was secure when the capture started or when it's sent. Each frame carries its own capture-time value: getSessionReplayState reports secure, and sendFullSnapshot passes it back as secureAtCapture. So a frame captured on a secure screen stays masked even if the flag clears (the screen is popped) before it goes out, and the plugin keeps no state between calls.
  • Platform-view captures (PixelCopy) are composited into the same frame, so they're covered too.
  • Native-screen bridge captures go through posthog-android's own replay, which gets its fix in fix(replay): mask FLAG_SECURE windows and discard frames with unplaceable masked views posthog-android#910.
  • iOS has no equivalent flag, so this is Android only.

💚 How did you test it?

  • New Kotlin unit tests:
    • a masked frame is all black at the original size;
    • getSessionReplayState reports the window's secure flag;
    • a frame captured on a secure window stays masked after the flag clears;
    • a frame is masked when the flag is set at send time, and not masked otherwise.
  • New Dart test: a frame captured while secure is true is sent with secureAtCapture: true. It fails if the argument is dropped.
  • ./gradlew :posthog_flutter:testDebugUnitTest, ktlint, dart format, dart analyze, flutter test: all pass.
  • Not tested on a device.

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

  • Claude Code. Anna chose to mask rather than skip, to match the posthog-android main fix.
  • Two pre-PR review cycles (review, then triage):
    • The first caught a bug in an earlier one-slot "secure" flag kept in the plugin: it could carry over and black out the next non-secure frame. A later independent review replaced that flag with the per-frame secureAtCapture value, so there's no state to carry over.
    • It also switched the masked path to read only the image size (inJustDecodeBounds) instead of decoding the secure pixels.
  • Deferred:
    • A test of the native sender call itself, which would need a new way to inject the sender.
    • One harmless edge case: if FLAG_SECURE clears while the screen looks exactly the same, the replay stays black until the screen changes.

🤖 Generated with Claude Code

@turnipdabeets turnipdabeets self-assigned this Oct 29, 2026
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 29, 2026 19:11

@posthog posthog Bot left a comment

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.

PostHog Review · Standard · reviewed 1d834ac: 1 Must fix.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

private fun getSessionReplayState(): Map<String, Any?> =
mapOf(
private fun getSessionReplayState(): Map<String, Any?> {
captureStartedOnSecureWindow = isWindowSecure()

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 · Track secure state across the actual screenshot capture

When FLAG_SECURE is set after this read but before renderObject.toImage(), then cleared during image processing, both checks return false and takeFrameMasked() sends the secure screenshot unmasked. captureScreenshot() awaits endOfFrame after this read, leaving the capture outside the sampled state; track secure transitions throughout capture or discard affected frames.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

posthog-flutter Compliance Report

Date: 2026-10-29 19:27:58 UTC
Duration: 96794ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

✅ 29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 142ms
Format Validation.Event Has Uuid ✅ 119ms
Format Validation.Event Has Lib Properties ✅ 115ms
Format Validation.Distinct Id Is String ✅ 115ms
Format Validation.Token Is Present ✅ 115ms
Format Validation.Custom Properties Preserved ✅ 116ms
Format Validation.Event Has Timestamp ✅ 117ms
Retry Behavior.Retries On 503 ✅ 5329ms
Retry Behavior.Does Not Retry On 400 ✅ 2118ms
Retry Behavior.Does Not Retry On 401 ✅ 2118ms
Retry Behavior.Respects Retry After Header ✅ 8125ms
Retry Behavior.Implements Backoff ✅ 15430ms
Retry Behavior.Retries On 500 ✅ 5225ms
Retry Behavior.Retries On 502 ✅ 5224ms
Retry Behavior.Retries On 504 ✅ 5225ms
Retry Behavior.Max Retries Respected ✅ 15443ms
Deduplication.Generates Unique Uuids ✅ 125ms
Deduplication.Preserves Uuid On Retry ✅ 5223ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10333ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5229ms
Deduplication.No Duplicate Events In Batch ✅ 124ms
Deduplication.Different Events Have Different Uuids ✅ 116ms
Compression.Sends Gzip When Enabled ✅ 116ms
Batch Format.Uses Proper Batch Structure ✅ 113ms
Batch Format.Flush With No Events Sends Nothing ✅ 108ms
Batch Format.Multiple Events Batched Together ✅ 123ms
Error Handling.Does Not Retry On 403 ✅ 2115ms
Error Handling.Does Not Retry On 413 ✅ 2117ms
Error Handling.Retries On 408 ✅ 5224ms

Feature_Flags Tests

✅ 16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 13ms
Request Payload.Flags Request Uses V2 Query Param ✅ 9ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 10ms
Request Payload.Flags Request Omits Authorization Header ✅ 9ms
Request Payload.Token In Flags Body Matches Init ✅ 10ms
Request Payload.Groups Round Trip ✅ 10ms
Request Payload.Groups Default To Empty Object ✅ 9ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It ✅ 10ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 9ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 10ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 10ms
Request Lifecycle.No Flags Request On Init Alone ✅ 4ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 113ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 15ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 9ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 116ms

@dustinbyrne dustinbyrne left a comment

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.

Reviewed the Android masking path and frame-state propagation. No actionable findings.

AI-assisted review.

@turnipdabeets
turnipdabeets merged commit 50c2c3e into main Oct 29, 2026
30 checks passed
@turnipdabeets
turnipdabeets deleted the fix/android-replay-flag-secure branch October 29, 2026 21:04
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