Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/quiet-masks-hold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-android": patch
---

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
5 changes: 5 additions & 0 deletions .changeset/secure-windows-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 fully masked session replay screenshot in screenshot mode, without their pixels or touches
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -371,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 {
Expand Down Expand Up @@ -447,6 +448,23 @@ public class PostHogReplayIntegration(
return Pair(imeVisible, event)
}

// 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.
internal fun touchInterceptor(window: Window): TouchEventInterceptor =
touchInterceptors.getOrPut(window) {
val windowRef = WeakReference(window)
TouchEventInterceptor { motionEvent, dispatch ->
if (isScreenshotCapable && windowRef.get()?.isSecure() == true) {
dispatch(motionEvent)
} else {
onTouchEventListener.intercept(motionEvent, dispatch)
}
}
}

internal val onTouchEventListener =
TouchEventInterceptor { motionEvent, dispatch ->
try {
Expand Down Expand Up @@ -555,7 +573,7 @@ public class PostHogReplayIntegration(
}

view.phoneWindow?.let { window ->
window.touchEventInterceptors -= onTouchEventListener
touchInterceptors.remove(window)?.let { window.touchEventInterceptors -= it }
}

decorViews.remove(view)
Expand Down Expand Up @@ -1116,6 +1134,7 @@ public class PostHogReplayIntegration(

var walkChildren = false

// Keep in sync with hasMaskableContent.
when {
view.isComposeView() -> {
findMaskableComposeWidgets(view, walk)
Expand Down Expand Up @@ -1175,7 +1194,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
}
Expand All @@ -1187,6 +1208,30 @@ 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
}
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)
Expand Down Expand Up @@ -1707,6 +1752,10 @@ public class PostHogReplayIntegration(
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()
Expand Down Expand Up @@ -1758,6 +1807,32 @@ public class PostHogReplayIntegration(
}
}

private fun Window.isSecure(): Boolean = attributes?.flags?.and(FLAG_SECURE) == FLAG_SECURE

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,
Expand All @@ -1769,6 +1844,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
Expand All @@ -1793,6 +1869,7 @@ public class PostHogReplayIntegration(
view.width,
view.height,
shouldVerifyMaskAlignment(view, drawState),
window.isSecure(),
)
} ?: return null
val viewId = System.identityHashCode(view)
Expand All @@ -1811,6 +1888,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) {
Expand Down Expand Up @@ -1934,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.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/**
Expand Down
Loading
Loading