Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/secure-wireframes-masked.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Window, TouchEventInterceptor>())

// 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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<RREvent>
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<RREvent>
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<RREvent>
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,
Expand Down
Loading