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/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
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 @@ -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 @@ -375,7 +376,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 @@ -451,6 +452,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, 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 (windowRef.get()?.isSecure() == true) {
dispatch(motionEvent)
} else {
onTouchEventListener.intercept(motionEvent, dispatch)
}
}
}

internal val onTouchEventListener =
TouchEventInterceptor { motionEvent, dispatch ->
try {
Expand Down Expand Up @@ -559,7 +577,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 @@ -797,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
Expand Down Expand Up @@ -1122,61 +1144,25 @@ public class PostHogReplayIntegration(
var walkChildren = false
var childrenInsideNoCapture = false

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) {
Expand All @@ -1189,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, childrenInsideNoCapture))
) {
walk.poisoned = true
return
}
Expand All @@ -1201,6 +1189,49 @@ 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.
internal fun View.hasMaskableContent(
walk: MaskWalk,
insideNoCapture: Boolean = false,
): Boolean {
if (!walk.visitedViews.add(System.identityHashCode(this))) {
return false
}
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 ->
getChildAt(i)?.let { it.visibility == View.VISIBLE && it.hasMaskableContent(walk) } == true
}
MaskRule.NONE -> false
}
}

private fun View.addGlobalVisibleRect(walk: MaskWalk) {
if (isViewStateStableForMatrixOperations()) {
getWindowVisibleRect(walk)
Expand Down Expand Up @@ -1721,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 @@ -1772,12 +1807,39 @@ 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 class ScreenshotGeometry(
val x: Int,
val y: Int,
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 @@ -1802,6 +1864,7 @@ public class PostHogReplayIntegration(
view.width,
view.height,
shouldVerifyMaskAlignment(view, drawState),
window.isSecure(),
)
} ?: return null
val viewId = System.identityHashCode(view)
Expand All @@ -1824,6 +1887,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(
targetWidth,
targetHeight,
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 @@ -1947,6 +2037,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 =
if (copySize != null) {
bitmap.downscaledWebpBase64(targetWidth, targetHeight, compressionQuality)
Expand Down Expand Up @@ -2003,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 @@ -38,6 +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.
* 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
Loading
Loading