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 99a7575b5..5acb25f5e 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( // 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) @@ -811,8 +811,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 @@ -2093,6 +2097,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 ad84813ac..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 @@ -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 @@ -668,6 +668,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,