From ecbab3a281e34ee464449b32f5c46332da266893 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 12:05:11 -0400 Subject: [PATCH 01/11] fix(replay): discard legacy frames with an unplaceable masked view --- .changeset/quiet-masks-hold.md | 5 ++ .../replay/PostHogReplayIntegration.kt | 29 +++++++++++- .../replay/PostHogReplayIntegrationTest.kt | 47 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-masks-hold.md diff --git a/.changeset/quiet-masks-hold.md b/.changeset/quiet-masks-hold.md new file mode 100644 index 000000000..bc2fa3032 --- /dev/null +++ b/.changeset/quiet-masks-hold.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Discard a session replay screenshot when a view with content to mask is animating or in a transient state, instead of recording that view unmasked 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 daec3775b..baa3eb693 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 @@ -1175,7 +1175,9 @@ public class PostHogReplayIntegration( if (!viewChild.isVisible(walk)) { // A skipped-but-rendered view could be a masked widget we cannot place. - if (walk.failClosed && viewChild.isRenderedButUnplaceable()) { + if (viewChild.isRenderedButUnplaceable() && + (walk.failClosed || viewChild.hasMaskableContent(walk)) + ) { walk.poisoned = true return } @@ -1187,6 +1189,29 @@ public class PostHogReplayIntegration( } } + // Whether this subtree holds anything the walk would mask, judged without geometry, so a + // legacy walk can tell an unplaceable sensitive view from a harmless animation. + private fun View.hasMaskableContent(walk: MaskWalk): Boolean { + if (!walk.visitedViews.add(System.identityHashCode(this))) { + return false + } + return when { + isComposeView() -> true + isUnmasked() -> false + isNoCapture() -> true + this is TextView -> + (text?.isNotEmpty() == true || hint?.isNotEmpty() == true) && shouldMaskTextView() + this is Spinner -> shouldMaskSpinner() + this is ImageView -> shouldMaskImage() + this is WebView -> isAnyInputSensitive() + this is ViewGroup -> + (0 until childCount).any { i -> + getChildAt(i)?.let { it.visibility == View.VISIBLE && it.hasMaskableContent(walk) } == true + } + else -> false + } + } + private fun View.addGlobalVisibleRect(walk: MaskWalk) { if (isViewStateStableForMatrixOperations()) { getWindowVisibleRect(walk) @@ -1703,7 +1728,7 @@ public class PostHogReplayIntegration( findMaskableWidgets(this, it) } } ?: return false - if (walk.aborted || unsafeRedraw()) { + if (walk.aborted || walk.poisoned || unsafeRedraw()) { config.logger.log("Session Replay screenshot discarded due to screen changes.") return 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 fcf0b0a72..ebd760b49 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 @@ -4191,6 +4191,53 @@ internal class PostHogReplayIntegrationTest { } } + @Test + @Config(sdk = [26], shadows = [DrawSequenceShadowPixelCopy::class]) + fun `legacy capture discards a frame with a masked view in transient state`() { + val h = screenshotCaptureHarness(enableMaskAlignmentVerification = false) + try { + DrawSequenceShadowPixelCopy.onRequest = { h.child.setHasTransientState(true) } + + h.fx.sut.captureSnapshot(WeakReference(h.hookLayout), WeakReference(h.window)) + + assertEquals(0, h.fake.captures) + } finally { + DrawSequenceShadowPixelCopy.onRequest = null + h.child.setHasTransientState(false) + h.fx.sut.uninstall() + } + } + + @Test + fun `legacy mask walk is not poisoned by an animating view with nothing to mask`() { + val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false) + try { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val root = FrameLayout(activity) + val animated = View(activity) + root.addView(animated) + activity.setContentView(root) + shadowOf(Looper.getMainLooper()).idle() + makeWindowVisible(activity.window.decorView) + root.layout(0, 0, 100, 100) + animated.layout(0, 0, 100, 20) + animated.animation = runningAnimation() + + val walk = PostHogReplayIntegration.MaskWalk(failClosed = false) + fx.sut.findMaskableWidgets(root, walk) + + assertFalse(walk.poisoned) + } finally { + fx.sut.uninstall() + } + } + + private fun runningAnimation(): Animation = + mock().also { + whenever(it.hasStarted()).thenReturn(true) + whenever(it.hasEnded()).thenReturn(false) + } + @Test @Config(sdk = [26], shadows = [DrawSequenceShadowPixelCopy::class]) fun `legacy capture stays pinned when verification is enabled mid-capture`() { From d4215484f51cf660ab2022cbbcaf6ab404d01d20 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 14:57:01 -0400 Subject: [PATCH 02/11] fix(replay): record FLAG_SECURE windows as a fully masked screenshot --- .changeset/secure-windows-masked.md | 5 ++ .../replay/PostHogReplayIntegration.kt | 54 +++++++++++++++++++ .../replay/PostHogReplayIntegrationTest.kt | 28 ++++++++++ 3 files changed, 87 insertions(+) create mode 100644 .changeset/secure-windows-masked.md diff --git a/.changeset/secure-windows-masked.md b/.changeset/secure-windows-masked.md new file mode 100644 index 000000000..1108bacf2 --- /dev/null +++ b/.changeset/secure-windows-masked.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Record windows that set `FLAG_SECURE` as a fully masked session replay screenshot instead of capturing their content 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 baa3eb693..2c71af920 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 @@ -36,6 +36,7 @@ import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.Window import android.view.WindowManager +import android.view.WindowManager.LayoutParams.FLAG_SECURE import android.webkit.WebView import android.widget.Button import android.widget.CheckBox @@ -1783,6 +1784,30 @@ public class PostHogReplayIntegration( } } + private fun maskedScreenshotBase64( + width: Int, + height: Int, + bitmapConfig: Bitmap.Config, + compressionQuality: Int, + ): String? { + val lease = + try { + pixelCopyBitmapBuffer.acquire(width, height, bitmapConfig) + } catch (e: Throwable) { + config.logger.log("Session Replay screenshot setup failed: $e.") + null + } ?: return null + return try { + lease.bitmap.eraseColor(paint.color) + lease.bitmap.webpBase64(compressionQuality) + } catch (e: Throwable) { + config.logger.log("Session Replay screenshot encoding failed: $e.") + null + } finally { + lease.release() + } + } + private fun scaledScreenshotDimension( size: Int, scale: Float, @@ -1794,6 +1819,7 @@ public class PostHogReplayIntegration( val width: Int, val height: Int, val verifyMaskAlignment: Boolean, + val secure: Boolean, ) // PixelCopy is only API >= 24 but this is already protected by the isSupported method @@ -1818,6 +1844,7 @@ public class PostHogReplayIntegration( view.width, view.height, shouldVerifyMaskAlignment(view, drawState), + window.attributes.flags and FLAG_SECURE != 0, ) } ?: return null val viewId = System.identityHashCode(view) @@ -1836,6 +1863,33 @@ public class PostHogReplayIntegration( } var base64: String? = null + // FLAG_SECURE asks that the window never be captured, so it's recorded as one mask + // without copying its pixels. + if (geometry.secure) { + base64 = + maskedScreenshotBase64( + scaledScreenshotDimension(sourceWidth, screenshotScale), + scaledScreenshotDimension(sourceHeight, screenshotScale), + bitmapConfig, + compressionQuality, + ) + if (base64 == null) { + recordScreenshotDiscarded(drawState) + return null + } + drawState.resetScreenshotDiscards() + return RRWireframe( + id = viewId, + x = x, + y = y, + width = width, + height = height, + type = "screenshot", + base64 = base64, + style = RRStyle(), + ) + } + val verifyMaskAlignment = geometry.verifyMaskAlignment val armedCapture = if (verifyMaskAlignment) { 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 ebd760b49..b582aef12 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 @@ -3084,6 +3084,34 @@ internal class PostHogReplayIntegrationTest { RecordingShadowPixelCopy.reset() } } + + @Test + @Config(sdk = [28], shadows = [RecordingShadowPixelCopy::class]) + @GraphicsMode(GraphicsMode.Mode.NATIVE) + fun `a secure window is recorded fully masked without copying its pixels`() { + val h = captureHarness() + h.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + RecordingShadowPixelCopy.reset() + try { + assertTrue(h.fx.sut.captureSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + assertTrue(RecordingShadowPixelCopy.requests.isEmpty()) + val bitmap = screenshotBitmap(h.fake) + try { + assertEquals(ceil(100 * scale).toInt(), bitmap.width) + for (x in 0 until bitmap.width) { + for (y in 0 until bitmap.height) { + val pixel = bitmap.getPixel(x, y) + assertTrue(Color.red(pixel) < 10 && Color.green(pixel) < 10 && Color.blue(pixel) < 10) + } + } + } finally { + bitmap.recycle() + } + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } } @RunWith(ParameterizedRobolectricTestRunner::class) From e78fb33e4dbb51a4f2e258b95ecd5f3f5ba39034 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 15:10:29 -0400 Subject: [PATCH 03/11] fix(replay): read FLAG_SECURE null-safely and cover reused bitmaps and nested masked views --- .changeset/quiet-masks-hold.md | 2 +- .changeset/secure-windows-masked.md | 2 +- .../replay/PostHogReplayIntegration.kt | 3 +- .../replay/PostHogReplayIntegrationTest.kt | 29 ++++++++++++++++--- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.changeset/quiet-masks-hold.md b/.changeset/quiet-masks-hold.md index bc2fa3032..c4f2c67c4 100644 --- a/.changeset/quiet-masks-hold.md +++ b/.changeset/quiet-masks-hold.md @@ -2,4 +2,4 @@ "posthog-android": patch --- -Discard a session replay screenshot when a view with content to mask is animating or in a transient state, instead of recording that view unmasked +Discard a session replay screenshot in screenshot mode when a view with content to mask is animating or in a transient state, instead of recording that view unmasked diff --git a/.changeset/secure-windows-masked.md b/.changeset/secure-windows-masked.md index 1108bacf2..82051c5d1 100644 --- a/.changeset/secure-windows-masked.md +++ b/.changeset/secure-windows-masked.md @@ -2,4 +2,4 @@ "posthog-android": patch --- -Record windows that set `FLAG_SECURE` as a fully masked session replay screenshot instead of capturing their content +Record windows that set `FLAG_SECURE` as a fully masked session replay screenshot in screenshot mode instead of capturing their content 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 2c71af920..ead23ac9d 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 @@ -1117,6 +1117,7 @@ public class PostHogReplayIntegration( var walkChildren = false + // Keep in sync with hasMaskableContent. when { view.isComposeView() -> { findMaskableComposeWidgets(view, walk) @@ -1844,7 +1845,7 @@ public class PostHogReplayIntegration( view.width, view.height, shouldVerifyMaskAlignment(view, drawState), - window.attributes.flags and FLAG_SECURE != 0, + window.attributes?.flags?.and(FLAG_SECURE) == FLAG_SECURE, ) } ?: return null val viewId = System.identityHashCode(view) 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 b582aef12..c32722219 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 @@ -52,6 +52,7 @@ import com.posthog.internal.replay.RREventType import com.posthog.internal.replay.RRFullSnapshotEvent import com.posthog.internal.replay.RRIncrementalMouseInteractionData import com.posthog.internal.replay.RRIncrementalMouseInteractionEvent +import com.posthog.internal.replay.RRIncrementalMutationData import com.posthog.internal.replay.RRMetaEvent import com.posthog.internal.replay.RRMouseInteraction import com.posthog.internal.replay.RRWireframe @@ -3019,7 +3020,17 @@ internal class PostHogReplayIntegrationTest { val events = fake.properties?.get("\$snapshot_data") as List val fullSnapshot = events.first { it.type == RREventType.FullSnapshot } val wireframes = (fullSnapshot.data as Map<*, *>)["wireframes"] as List<*> - val wireframe = wireframes.single() as RRWireframe + return decodeScreenshot(wireframes.single() as RRWireframe) + } + + private fun updatedScreenshotBitmap(fake: PostHogFake): Bitmap { + @Suppress("UNCHECKED_CAST") + val events = fake.properties?.get("\$snapshot_data") as List + val mutation = events.first { it.type == RREventType.IncrementalSnapshot }.data as RRIncrementalMutationData + return decodeScreenshot(assertNotNull(mutation.updates).single().wireframe) + } + + private fun decodeScreenshot(wireframe: RRWireframe): Bitmap { val bytes = Base64.decode(assertNotNull(wireframe.base64).substringAfter(','), Base64.DEFAULT) return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } @@ -3090,12 +3101,16 @@ internal class PostHogReplayIntegrationTest { @GraphicsMode(GraphicsMode.Mode.NATIVE) fun `a secure window is recorded fully masked without copying its pixels`() { val h = captureHarness() - h.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) RecordingShadowPixelCopy.reset() + RecordingShadowPixelCopy.onRequest = { it.eraseColor(Color.WHITE) } try { assertTrue(h.fx.sut.captureSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) - assertTrue(RecordingShadowPixelCopy.requests.isEmpty()) - val bitmap = screenshotBitmap(h.fake) + h.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + + assertTrue(h.fx.sut.captureSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + + assertEquals(1, RecordingShadowPixelCopy.requests.size) + val bitmap = updatedScreenshotBitmap(h.fake) try { assertEquals(ceil(100 * scale).toInt(), bitmap.width) for (x in 0 until bitmap.width) { @@ -4223,6 +4238,12 @@ internal class PostHogReplayIntegrationTest { @Config(sdk = [26], shadows = [DrawSequenceShadowPixelCopy::class]) fun `legacy capture discards a frame with a masked view in transient state`() { val h = screenshotCaptureHarness(enableMaskAlignmentVerification = false) + val container = FrameLayout(h.hookLayout.context) + h.hookLayout.removeView(h.child) + container.addView(h.child) + h.hookLayout.addView(container) + container.layout(0, 0, 100, 20) + h.child.layout(0, 0, 100, 20) try { DrawSequenceShadowPixelCopy.onRequest = { h.child.setHasTransientState(true) } From 64b195ea88ad786902dc07e3930aa4e8485d1105 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 15:15:42 -0400 Subject: [PATCH 04/11] test(replay): cover plain masked text in the legacy transient-state discard --- .../com/posthog/android/replay/PostHogReplayIntegrationTest.kt | 2 ++ 1 file changed, 2 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 c32722219..45f7239e7 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 @@ -4244,6 +4244,8 @@ internal class PostHogReplayIntegrationTest { h.hookLayout.addView(container) container.layout(0, 0, 100, 20) h.child.layout(0, 0, 100, 20) + h.child.tag = null + h.child.text = "secret" try { DrawSequenceShadowPixelCopy.onRequest = { h.child.setHasTransientState(true) } From e23f2a88dbc3f25c49a6179681031ef5bc4569b6 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:16:19 -0400 Subject: [PATCH 05/11] fix(replay): drop touches on FLAG_SECURE windows in screenshot mode Also splits the legacy discard log so a transient-state discard says why, documents the FLAG_SECURE behaviour on the screenshot option, and points the duplicated mask rules at each other. --- .changeset/secure-windows-masked.md | 2 +- .../replay/PostHogReplayIntegration.kt | 28 ++++++++++++-- .../replay/PostHogSessionReplayConfig.kt | 1 + .../replay/PostHogReplayIntegrationTest.kt | 37 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/.changeset/secure-windows-masked.md b/.changeset/secure-windows-masked.md index 82051c5d1..db5064041 100644 --- a/.changeset/secure-windows-masked.md +++ b/.changeset/secure-windows-masked.md @@ -2,4 +2,4 @@ "posthog-android": patch --- -Record windows that set `FLAG_SECURE` as a fully masked session replay screenshot in screenshot mode instead of capturing their content +Record windows that set `FLAG_SECURE` as a fully masked session replay screenshot in screenshot mode, without their pixels 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 ead23ac9d..1305ae7e6 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 @@ -372,7 +372,7 @@ public class PostHogReplayIntegration( } } - window.touchEventInterceptors += onTouchEventListener + window.touchEventInterceptors += touchInterceptor(window) // TODO: can check if user pressed hardware back button (KEYCODE_BACK) // window.keyEventInterceptors } else { @@ -448,6 +448,23 @@ public class PostHogReplayIntegration( return Pair(imeVisible, event) } + // 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. + 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) { + dispatch(motionEvent) + } else { + onTouchEventListener.intercept(motionEvent, dispatch) + } + } + } + internal val onTouchEventListener = TouchEventInterceptor { motionEvent, dispatch -> try { @@ -556,7 +573,7 @@ public class PostHogReplayIntegration( } view.phoneWindow?.let { window -> - window.touchEventInterceptors -= onTouchEventListener + touchInterceptors.remove(window)?.let { window.touchEventInterceptors -= it } } decorViews.remove(view) @@ -1193,6 +1210,7 @@ public class PostHogReplayIntegration( // Whether this subtree holds anything the walk would mask, judged without geometry, so a // legacy walk can tell an unplaceable sensitive view from a harmless animation. + // Keep in sync with findMaskableWidgets. private fun View.hasMaskableContent(walk: MaskWalk): Boolean { if (!walk.visitedViews.add(System.identityHashCode(this))) { return false @@ -1730,10 +1748,14 @@ public class PostHogReplayIntegration( findMaskableWidgets(this, it) } } ?: return false - if (walk.aborted || walk.poisoned || unsafeRedraw()) { + if (walk.aborted || unsafeRedraw()) { config.logger.log("Session Replay screenshot discarded due to screen changes.") return false } + if (walk.poisoned) { + config.logger.log("Session Replay screenshot discarded: a view with content to mask is animating or in a transient state.") + return false + } return bitmap.paintScreenshotMasks(walk.rects, sourceWidth, sourceHeight) { val safe = !unsafeRedraw() 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 bc3c492a8..f0dc6122a 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,6 +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. */ 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 45f7239e7..2272df046 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 @@ -629,6 +629,43 @@ internal class PostHogReplayIntegrationTest { } } + @Test + fun `a touch on a secure window is not recorded in screenshot mode`() { + 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 + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + assertTrue(sut.isActive()) + val window = Robolectric.buildActivity(Activity::class.java).setup().get().window + val interceptor = sut.touchInterceptor(window) + val touch = { + val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 0f, 0f, 0) + val state = interceptor.intercept(event) { DispatchState.Consumed } + event.recycle() + awaitReplayExecutors() + state + } + + assertEquals(DispatchState.Consumed, touch()) + val submitsBeforeSecure = submits.get() + assertTrue(submitsBeforeSecure >= 1) + + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + + assertEquals(DispatchState.Consumed, touch()) + assertEquals(submitsBeforeSecure, submits.get()) + } finally { + sut.uninstall() + } + } + private fun dispatchTouch( sut: PostHogReplayIntegration, action: Int = MotionEvent.ACTION_DOWN, From 5355e7669351d95ed1d563c7e5f8cc94a53013dd Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:25:09 -0400 Subject: [PATCH 06/11] fix(replay): mask the frame when a window turns secure during the copy --- .../replay/PostHogReplayIntegration.kt | 10 ++++-- .../replay/PostHogReplayIntegrationTest.kt | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) 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..6fde07b7a 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 @@ -457,7 +457,7 @@ public class PostHogReplayIntegration( touchInterceptors.getOrPut(window) { val windowRef = WeakReference(window) TouchEventInterceptor { motionEvent, dispatch -> - if (isScreenshotCapable && windowRef.get()?.attributes?.flags?.and(FLAG_SECURE) == FLAG_SECURE) { + if (isScreenshotCapable && windowRef.get()?.isSecure() == true) { dispatch(motionEvent) } else { onTouchEventListener.intercept(motionEvent, dispatch) @@ -1807,6 +1807,8 @@ public class PostHogReplayIntegration( } } + private fun Window.isSecure(): Boolean = attributes?.flags?.and(FLAG_SECURE) == FLAG_SECURE + private fun maskedScreenshotBase64( width: Int, height: Int, @@ -1867,7 +1869,7 @@ public class PostHogReplayIntegration( view.width, view.height, shouldVerifyMaskAlignment(view, drawState), - window.attributes?.flags?.and(FLAG_SECURE) == FLAG_SECURE, + window.isSecure(), ) } ?: return null val viewId = System.identityHashCode(view) @@ -2036,6 +2038,10 @@ public class PostHogReplayIntegration( releaseFromWaiter = true if (requestState.succeeded()) { try { + // The window can turn secure while the copy runs. + if (window.isSecure()) { + bitmap.eraseColor(paint.color) + } base64 = bitmap.webpBase64(compressionQuality) } catch (e: Throwable) { config.logger.log("Session Replay screenshot encoding failed: $e.") 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..769e60ed6 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 @@ -3164,6 +3164,38 @@ internal class PostHogReplayIntegrationTest { RecordingShadowPixelCopy.reset() } } + + @Test + @Config(sdk = [28], shadows = [RecordingShadowPixelCopy::class]) + @GraphicsMode(GraphicsMode.Mode.NATIVE) + fun `a window that turns secure during the copy is recorded fully masked`() { + val h = captureHarness() + RecordingShadowPixelCopy.reset() + RecordingShadowPixelCopy.onRequest = { + it.eraseColor(Color.WHITE) + // Without a relayout, so the copy isn't discarded as a redraw. + h.window.attributes.flags = h.window.attributes.flags or WindowManager.LayoutParams.FLAG_SECURE + } + try { + assertTrue(h.fx.sut.captureSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + + assertEquals(1, RecordingShadowPixelCopy.requests.size) + val bitmap = screenshotBitmap(h.fake) + try { + for (x in 0 until bitmap.width) { + for (y in 0 until bitmap.height) { + val pixel = bitmap.getPixel(x, y) + assertTrue(Color.red(pixel) < 10 && Color.green(pixel) < 10 && Color.blue(pixel) < 10) + } + } + } finally { + bitmap.recycle() + } + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } } @RunWith(ParameterizedRobolectricTestRunner::class) From 40c96db224dac574dd56bee5258fd5343f5dde8e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:46:40 -0400 Subject: [PATCH 07/11] fix(replay): guard the touch interceptor map and test secure touches through the window --- .../posthog/android/replay/PostHogReplayIntegration.kt | 4 ++-- .../android/replay/PostHogReplayIntegrationTest.kt | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) 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 6fde07b7a..99a7575b5 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 @@ -448,8 +448,8 @@ public class PostHogReplayIntegration( return Pair(imeVisible, event) } - // Main thread only, like the window callbacks that add and remove these interceptors. - private val touchInterceptors = WeakHashMap() + // Window callbacks add these on the main thread; install() and uninstall() remove them on the caller's. + private val touchInterceptors = Collections.synchronizedMap(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. 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 769e60ed6..1d27412a3 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 @@ -644,22 +644,21 @@ internal class PostHogReplayIntegrationTest { shadowOf(Looper.getMainLooper()).idle() assertTrue(sut.isActive()) val window = Robolectric.buildActivity(Activity::class.java).setup().get().window - val interceptor = sut.touchInterceptor(window) + shadowOf(Looper.getMainLooper()).idle() val touch = { val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 0f, 0f, 0) - val state = interceptor.intercept(event) { DispatchState.Consumed } + window.callback.dispatchTouchEvent(event) event.recycle() awaitReplayExecutors() - state } - assertEquals(DispatchState.Consumed, touch()) + touch() val submitsBeforeSecure = submits.get() assertTrue(submitsBeforeSecure >= 1) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) - assertEquals(DispatchState.Consumed, touch()) + touch() assertEquals(submitsBeforeSecure, submits.get()) } finally { sut.uninstall() From ca1620ef0b9d10ea7d7cf20f937c714187614b6a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:51:55 -0400 Subject: [PATCH 08/11] test(replay): register the secure-touch test's window through mocked Curtains roots --- .../posthog/android/replay/PostHogReplayIntegrationTest.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 1d27412a3..ad84813ac 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 @@ -637,14 +637,15 @@ internal class PostHogReplayIntegrationTest { val sut = getSutWithExecutor(config, countingReplayExecutor(submits)) val fake = createPostHogFake() fake.sessionReplayActive = false + val controller = Robolectric.buildActivity(Activity::class.java).setup() + val window = controller.get().window + val curtains = mockCurtainsRoot(window.decorView) sut.install(fake) try { PostHogSessionManager.startSession() sut.onSessionIdChanged() shadowOf(Looper.getMainLooper()).idle() assertTrue(sut.isActive()) - val window = Robolectric.buildActivity(Activity::class.java).setup().get().window - shadowOf(Looper.getMainLooper()).idle() val touch = { val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 0f, 0f, 0) window.callback.dispatchTouchEvent(event) @@ -662,6 +663,8 @@ internal class PostHogReplayIntegrationTest { assertEquals(submitsBeforeSecure, submits.get()) } finally { sut.uninstall() + curtains.close() + controller.pause().stop().destroy() } } From cc97d4bc024fe8406259e0339fa21dbb71a27210 Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:29:40 -0400 Subject: [PATCH 09/11] refactor(replay): share one mask classifier between the mask walk and the discard check (#913) * refactor(replay): share one mask classifier between the mask walk and the discard check * test(replay): cover Compose and hidden children in the mask classifier parity test Drops the refactor changeset: an internal refactor has no user-facing changelog entry. --------- Co-authored-by: Claude Opus 5.5 --- .../replay/PostHogReplayIntegration.kt | 101 +++++++----------- .../replay/PostHogReplayIntegrationTest.kt | 55 ++++++++++ 2 files changed, 95 insertions(+), 61 deletions(-) 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 2e3de3185..6b7a0a558 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 @@ -1140,62 +1140,25 @@ public class PostHogReplayIntegration( var walkChildren = false var childrenInsideNoCapture = false - // Keep in sync with hasMaskableContent. - when { - view.isComposeView() -> { - if (insideNoCapture || view.isNoCapture()) { - view.addGlobalVisibleRect(walk) - childrenInsideNoCapture = true - } else { - findMaskableComposeWidgets(view, walk) - if (walk.shouldStop) { - return - } + when (view.maskRule(insideNoCapture)) { + MaskRule.COMPOSE -> { + findMaskableComposeWidgets(view, walk) + if (walk.shouldStop) { + return } // Also walk View children for interop scenarios (AndroidView, FragmentContainerView, etc.) walkChildren = true } - - view.isUnmasked() -> { - // ph-no-mask has precedence, skip masking - } - // Children of an unclipped container can draw outside its rect, so they are masked too. - insideNoCapture || view.isNoCapture() -> { + MaskRule.NO_CAPTURE -> { view.addGlobalVisibleRect(walk) walkChildren = true childrenInsideNoCapture = true } - - view is TextView -> { - // Only emptiness matters here; toString() would copy the text on every walk. - val hasContent = view.text?.isNotEmpty() == true || view.hint?.isNotEmpty() == true - if (hasContent && view.shouldMaskTextView()) { - view.addTextAreaGlobalVisibleRect(walk) - } - } - - view is Spinner -> { - if (view.shouldMaskSpinner()) { - view.addGlobalVisibleRect(walk) - } - } - - view is ImageView -> { - if (view.shouldMaskImage()) { - view.addGlobalVisibleRect(walk) - } - } - - view is WebView -> { - if (view.isAnyInputSensitive()) { - view.addGlobalVisibleRect(walk) - } - } - - view is ViewGroup && view.childCount > 0 -> { - walkChildren = true - } + MaskRule.MASK_VIEW -> view.addGlobalVisibleRect(walk) + MaskRule.MASK_TEXT -> (view as TextView).addTextAreaGlobalVisibleRect(walk) + MaskRule.CHILDREN -> walkChildren = true + MaskRule.NONE -> Unit } if (walkChildren && view is ViewGroup && view.childCount > 0) { @@ -1222,27 +1185,43 @@ public class PostHogReplayIntegration( } } + internal enum class MaskRule { COMPOSE, NO_CAPTURE, MASK_VIEW, MASK_TEXT, CHILDREN, NONE } + + // The one place that decides how a view is masked, shared by the mask walk and + // hasMaskableContent so the two can't disagree. + internal fun View.maskRule(insideNoCapture: Boolean = false): MaskRule = + when { + isComposeView() -> if (insideNoCapture || isNoCapture()) MaskRule.NO_CAPTURE else MaskRule.COMPOSE + // ph-no-mask has precedence, skip masking + isUnmasked() -> MaskRule.NONE + insideNoCapture || isNoCapture() -> MaskRule.NO_CAPTURE + // Only emptiness matters here; toString() would copy the text on every walk. + this is TextView -> + if ((text?.isNotEmpty() == true || hint?.isNotEmpty() == true) && shouldMaskTextView()) { + MaskRule.MASK_TEXT + } else { + MaskRule.NONE + } + this is Spinner -> if (shouldMaskSpinner()) MaskRule.MASK_VIEW else MaskRule.NONE + this is ImageView -> if (shouldMaskImage()) MaskRule.MASK_VIEW else MaskRule.NONE + this is WebView -> if (isAnyInputSensitive()) MaskRule.MASK_VIEW else MaskRule.NONE + this is ViewGroup && childCount > 0 -> MaskRule.CHILDREN + else -> MaskRule.NONE + } + // Whether this subtree holds anything the walk would mask, judged without geometry, so a // legacy walk can tell an unplaceable sensitive view from a harmless animation. - // Keep in sync with findMaskableWidgets. - private fun View.hasMaskableContent(walk: MaskWalk): Boolean { + internal fun View.hasMaskableContent(walk: MaskWalk): Boolean { if (!walk.visitedViews.add(System.identityHashCode(this))) { return false } - return when { - isComposeView() -> true - isUnmasked() -> false - isNoCapture() -> true - this is TextView -> - (text?.isNotEmpty() == true || hint?.isNotEmpty() == true) && shouldMaskTextView() - this is Spinner -> shouldMaskSpinner() - this is ImageView -> shouldMaskImage() - this is WebView -> isAnyInputSensitive() - this is ViewGroup -> - (0 until childCount).any { i -> + return when (maskRule()) { + MaskRule.COMPOSE, MaskRule.NO_CAPTURE, MaskRule.MASK_VIEW, MaskRule.MASK_TEXT -> true + MaskRule.CHILDREN -> + (0 until (this as ViewGroup).childCount).any { i -> getChildAt(i)?.let { it.visibility == View.VISIBLE && it.hasMaskableContent(walk) } == true } - else -> false + MaskRule.NONE -> 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 de57b6f83..436f54269 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 @@ -15,10 +15,12 @@ import android.util.Base64 import android.view.MotionEvent import android.view.PixelCopy import android.view.View +import android.view.ViewGroup import android.view.Window import android.view.WindowManager import android.view.animation.Animation import android.widget.FrameLayout +import android.widget.ImageView import android.widget.TextView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -4545,6 +4547,59 @@ internal class PostHogReplayIntegrationTest { } } + @Test + fun `the discard check treats everything the mask walk masks as maskable`() { + val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false) + try { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val root = FrameLayout(activity) + activity.setContentView(root) + val views = + listOf( + View(activity), + View(activity).apply { tag = "ph-no-capture" }, + TextView(activity), + TextView(activity).apply { text = "secret" }, + TextView(activity).apply { + text = "secret" + tag = "ph-no-mask" + }, + ImageView(activity).apply { setImageBitmap(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) }, + FrameLayout(activity).apply { addView(TextView(activity).apply { text = "secret" }) }, + FrameLayout(activity).apply { addView(View(activity)) }, + FrameLayout(activity).apply { + addView( + TextView(activity).apply { + text = "secret" + visibility = View.INVISIBLE + }, + ) + }, + FakeAndroidComposeView(activity), + ) + views.forEach { root.addView(it) } + shadowOf(Looper.getMainLooper()).idle() + makeWindowVisible(activity.window.decorView) + root.layout(0, 0, 100, 100) + views.forEach { view -> + view.layout(0, 0, 100, 20) + (view as? ViewGroup)?.getChildAt(0)?.layout(0, 0, 100, 20) + } + + val maskedByWalk = + views.map { view -> + PostHogReplayIntegration.MaskWalk(failClosed = false).also { fx.sut.findMaskableWidgets(view, it) }.rects.isNotEmpty() + } + val maskableForDiscard = views.map { with(fx.sut) { it.hasMaskableContent(PostHogReplayIntegration.MaskWalk()) } } + + assertEquals(listOf(false, true, false, true, false, true, true, false, false, false), maskedByWalk) + // Compose content can't be judged without geometry, so the discard check counts it as maskable. + assertEquals(listOf(false, true, false, true, false, true, true, false, false, true), maskableForDiscard) + } finally { + fx.sut.uninstall() + } + } + @Test fun `legacy mask walk is not poisoned by an animating view with nothing to mask`() { val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false) From fd1d13777347c8e3da1ffbf5feaf2d2115182fb9 Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:29:59 -0400 Subject: [PATCH 10/11] fix(replay): honour FLAG_SECURE in wireframe mode (#912) * fix(replay): record FLAG_SECURE windows as a black wireframe and drop their touches in all modes * fix(replay): mask the wireframe when a window turns secure during the walk * test(replay): cover the wireframe secure check before the walk --- .changeset/secure-wireframes-masked.md | 5 ++ .../replay/PostHogReplayIntegration.kt | 31 +++++++- .../replay/PostHogSessionReplayConfig.kt | 2 +- .../replay/PostHogReplayIntegrationTest.kt | 76 ++++++++++++++++++- 4 files changed, 107 insertions(+), 7 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 6b7a0a558..d5bf720a0 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 @@ -455,13 +455,13 @@ public class PostHogReplayIntegration( // Window callbacks add these on the main thread; install() and uninstall() remove them on the caller's. private val touchInterceptors = Collections.synchronizedMap(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()?.isSecure() == true) { + if (windowRef.get()?.isSecure() == true) { dispatch(motionEvent) } else { onTouchEventListener.intercept(motionEvent, dispatch) @@ -815,8 +815,12 @@ public class PostHogReplayIntegration( window, status.drawState, ) ?: return false + } 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 @@ -2090,6 +2094,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 562c7718a..43b908476 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 @@ -38,7 +38,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 436f54269..537031d23 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 @@ -107,6 +107,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) @@ -698,10 +699,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 @@ -736,6 +736,78 @@ 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() + } + } + + @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() + 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 1bf312d3bffdb547fb9cf0ada1d4d3162263c609 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 11:36:21 -0400 Subject: [PATCH 11/11] fix(replay): treat no-capture descendants as maskable in the discard check Co-Authored-By: Claude Opus 5.5 --- .../replay/PostHogReplayIntegration.kt | 9 ++++-- .../replay/PostHogReplayIntegrationTest.kt | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) 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 d5bf720a0..39200c312 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 @@ -1176,7 +1176,7 @@ public class PostHogReplayIntegration( if (!viewChild.isVisible(walk)) { // A skipped-but-rendered view could be a masked widget we cannot place. if (viewChild.isRenderedButUnplaceable() && - (walk.failClosed || viewChild.hasMaskableContent(walk)) + (walk.failClosed || viewChild.hasMaskableContent(walk, childrenInsideNoCapture)) ) { walk.poisoned = true return @@ -1215,11 +1215,14 @@ public class PostHogReplayIntegration( // Whether this subtree holds anything the walk would mask, judged without geometry, so a // legacy walk can tell an unplaceable sensitive view from a harmless animation. - internal fun View.hasMaskableContent(walk: MaskWalk): Boolean { + internal fun View.hasMaskableContent( + walk: MaskWalk, + insideNoCapture: Boolean = false, + ): Boolean { if (!walk.visitedViews.add(System.identityHashCode(this))) { return false } - return when (maskRule()) { + return when (maskRule(insideNoCapture)) { MaskRule.COMPOSE, MaskRule.NO_CAPTURE, MaskRule.MASK_VIEW, MaskRule.MASK_TEXT -> true MaskRule.CHILDREN -> (0 until (this as ViewGroup).childCount).any { i -> 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 537031d23..b3fa037d0 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 @@ -4696,6 +4696,38 @@ internal class PostHogReplayIntegrationTest { } } + // An animating view can't be placed, so the walk would otherwise ship its overflow unmasked. + @Test + fun `legacy mask walk is poisoned by an animating no-capture descendant only`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val root = FrameLayout(activity).apply { clipChildren = false } + val wrapper = + FrameLayout(activity).apply { + tag = "ph-no-capture" + clipChildren = false + clipToPadding = false + } + val plain = View(activity) + wrapper.addView(plain) + root.addView(wrapper) + activity.setContentView(root) + shadowOf(Looper.getMainLooper()).idle() + makeWindowVisible(activity.window.decorView) + root.layout(0, 0, 100, 400) + wrapper.layout(0, 0, 100, 20) + plain.layout(0, 60, 100, 80) + plain.animation = runningAnimation() + val sut = PostHogReplayIntegration(activity, PostHogAndroidConfig(PROJECT_TOKEN), MainHandler()) + + fun legacyWalk() = PostHogReplayIntegration.MaskWalk(failClosed = false).also { sut.findMaskableWidgets(root, it) } + + assertTrue(legacyWalk().poisoned) + + // ph-no-mask has precedence, so the walk would not have masked it anyway. + plain.tag = "ph-no-mask" + assertFalse(legacyWalk().poisoned) + } + private fun runningAnimation(): Animation = mock().also { whenever(it.hasStarted()).thenReturn(true)