From e5ffd462eb86b22c29b69fdda1863dfe696610e5 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:20:31 -0400 Subject: [PATCH 1/3] fix(replay): record FLAG_SECURE windows as a black wireframe and drop their touches in all modes --- .changeset/secure-wireframes-masked.md | 5 ++++ .../replay/PostHogReplayIntegration.kt | 27 ++++++++++++++++--- .../replay/PostHogSessionReplayConfig.kt | 2 +- .../replay/PostHogReplayIntegrationTest.kt | 27 +++++++++++++++++-- 4 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 .changeset/secure-wireframes-masked.md diff --git a/.changeset/secure-wireframes-masked.md b/.changeset/secure-wireframes-masked.md new file mode 100644 index 000000000..c9af9ccb0 --- /dev/null +++ b/.changeset/secure-wireframes-masked.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Record windows that set `FLAG_SECURE` as a solid black session replay frame in wireframe mode, without their views, text or touches diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index 1305ae7e6..7e22bab03 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -451,13 +451,13 @@ public class PostHogReplayIntegration( // Main thread only, like the window callbacks that add and remove these interceptors. private val touchInterceptors = WeakHashMap() - // A FLAG_SECURE window is recorded fully masked in screenshot mode, so its touches aren't - // recorded either. The flag is read per touch because apps toggle it at runtime. + // A FLAG_SECURE window is recorded fully masked, so its touches aren't recorded either. + // The flag is read per touch because apps toggle it at runtime. internal fun touchInterceptor(window: Window): TouchEventInterceptor = touchInterceptors.getOrPut(window) { val windowRef = WeakReference(window) TouchEventInterceptor { motionEvent, dispatch -> - if (isScreenshotCapable && windowRef.get()?.attributes?.flags?.and(FLAG_SECURE) == FLAG_SECURE) { + if (windowRef.get()?.attributes?.flags?.and(FLAG_SECURE) == FLAG_SECURE) { dispatch(motionEvent) } else { onTouchEventListener.intercept(motionEvent, dispatch) @@ -811,6 +811,8 @@ public class PostHogReplayIntegration( window, status.drawState, ) ?: return false + } else if (window.attributes?.flags?.and(FLAG_SECURE) == FLAG_SECURE) { + view.toSecureWireframe() ?: return false } else { view.toWireframe() ?: return false } @@ -2087,6 +2089,25 @@ public class PostHogReplayIntegration( return this.isTextInputSensitive(ancestorUnmasked) } + // A FLAG_SECURE window is recorded as one black rectangle, with nothing from its view tree. + private fun View.toSecureWireframe(): RRWireframe? { + if (!isVisible()) { + return null + } + val coordinates = IntArray(2) + if (isViewStateStableForMatrixOperations()) { + getLocationOnScreen(coordinates) + } + return RRWireframe( + id = System.identityHashCode(this), + x = coordinates[0].densityValue(screenDensity), + y = coordinates[1].densityValue(screenDensity), + width = width.densityValue(screenDensity), + height = height.densityValue(screenDensity), + style = RRStyle().apply { backgroundColor = "#000000" }, + ) + } + private fun View.toWireframe( parentId: Int? = null, ancestorUnmasked: Boolean = false, diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt index f0dc6122a..b4b8cf24f 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt @@ -36,7 +36,7 @@ public class PostHogSessionReplayConfig * By default Session replay will capture all the views on the screen as a wireframe, * By enabling this option, PostHog will capture the screenshot of the screen. * The screenshot may contain sensitive information, use with caution. - * Windows that set `FLAG_SECURE` are recorded as a solid black frame, and their touches are not recorded. + * In both modes, windows that set `FLAG_SECURE` are recorded as a solid black frame, and their touches are not recorded. */ public var screenshot: Boolean = false, /** diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index 2272df046..5818b5d8a 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -105,6 +105,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -630,10 +631,9 @@ internal class PostHogReplayIntegrationTest { } @Test - fun `a touch on a secure window is not recorded in screenshot mode`() { + fun `a touch on a secure window is not recorded`() { val submits = AtomicInteger(0) val config = configWithSampling(flagActive = true, samplingPasses = true) - config.sessionReplayConfig.screenshot = true val sut = getSutWithExecutor(config, countingReplayExecutor(submits)) val fake = createPostHogFake() fake.sessionReplayActive = false @@ -666,6 +666,29 @@ internal class PostHogReplayIntegrationTest { } } + @Test + fun `a secure window is recorded as one black wireframe in wireframe mode`() { + val h = screenshotCaptureHarness() + try { + h.fx.config.sessionReplayConfig.screenshot = false + h.child.tag = null + h.child.text = "secret" + h.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + + h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window)) + + @Suppress("UNCHECKED_CAST") + val events = h.fake.properties?.get("\$snapshot_data") as List + val fullSnapshot = events.first { it.type == RREventType.FullSnapshot } + val wireframe = ((fullSnapshot.data as Map<*, *>)["wireframes"] as List<*>).single() as RRWireframe + assertEquals("#000000", wireframe.style?.backgroundColor) + assertNull(wireframe.childWireframes) + assertNull(wireframe.text) + } finally { + h.fx.sut.uninstall() + } + } + private fun dispatchTouch( sut: PostHogReplayIntegration, action: Int = MotionEvent.ACTION_DOWN, From 3b1ade7d9ccb9cea03f18ac2173aeeb0463a7c08 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:32:07 -0400 Subject: [PATCH 2/3] fix(replay): mask the wireframe when a window turns secure during the walk --- .../replay/PostHogReplayIntegration.kt | 4 +++- .../replay/PostHogReplayIntegrationTest.kt | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index 59c71bbcc..f2b66b681 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -814,7 +814,9 @@ public class PostHogReplayIntegration( } else if (window.isSecure()) { view.toSecureWireframe() ?: return false } else { - view.toWireframe() ?: return false + val tree = view.toWireframe() ?: return false + // The window can turn secure while the tree is walked. + if (window.isSecure()) view.toSecureWireframe() ?: return false else tree } // if the decorView has no backgroundColor, we use the theme color diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index 78452023d..40c9f6d7f 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -689,6 +689,30 @@ internal class PostHogReplayIntegrationTest { } } + @Test + fun `a window that turns secure during the wireframe walk is recorded as one black wireframe`() { + val h = screenshotCaptureHarness() + try { + h.fx.config.sessionReplayConfig.screenshot = false + h.child.tag = null + h.child.text = "secret" + h.hookLayout.onWalkTouch = { + h.window.attributes.flags = h.window.attributes.flags or WindowManager.LayoutParams.FLAG_SECURE + } + + h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window)) + + @Suppress("UNCHECKED_CAST") + val events = h.fake.properties?.get("\$snapshot_data") as List + val fullSnapshot = events.first { it.type == RREventType.FullSnapshot } + val wireframe = ((fullSnapshot.data as Map<*, *>)["wireframes"] as List<*>).single() as RRWireframe + assertEquals("#000000", wireframe.style?.backgroundColor) + assertNull(wireframe.childWireframes) + } finally { + h.fx.sut.uninstall() + } + } + private fun dispatchTouch( sut: PostHogReplayIntegration, action: Int = MotionEvent.ACTION_DOWN, From 4778457b97433053894b64b48628342d2d2fd672 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:54:52 -0400 Subject: [PATCH 3/3] test(replay): cover the wireframe secure check before the walk --- .../replay/PostHogReplayIntegrationTest.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index 809681216..03635e443 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -691,6 +691,31 @@ internal class PostHogReplayIntegrationTest { } } + @Test + fun `a window secure when the wireframe walk starts is recorded as one black wireframe`() { + val h = screenshotCaptureHarness() + try { + h.fx.config.sessionReplayConfig.screenshot = false + h.child.tag = null + h.child.text = "secret" + h.window.attributes.flags = h.window.attributes.flags or WindowManager.LayoutParams.FLAG_SECURE + h.hookLayout.onWalkTouch = { + h.window.attributes.flags = h.window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE.inv() + } + + h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window)) + + @Suppress("UNCHECKED_CAST") + val events = h.fake.properties?.get("\$snapshot_data") as List + val fullSnapshot = events.first { it.type == RREventType.FullSnapshot } + val wireframe = ((fullSnapshot.data as Map<*, *>)["wireframes"] as List<*>).single() as RRWireframe + assertEquals("#000000", wireframe.style?.backgroundColor) + assertNull(wireframe.childWireframes) + } finally { + h.fx.sut.uninstall() + } + } + @Test fun `a window that turns secure during the wireframe walk is recorded as one black wireframe`() { val h = screenshotCaptureHarness()