diff --git a/.changeset/quiet-masks-hold.md b/.changeset/quiet-masks-hold.md new file mode 100644 index 000000000..c4f2c67c4 --- /dev/null +++ b/.changeset/quiet-masks-hold.md @@ -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 diff --git a/.changeset/secure-windows-masked.md b/.changeset/secure-windows-masked.md new file mode 100644 index 000000000..db5064041 --- /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 in screenshot mode, without their pixels or touches 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 8b1592c47..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 @@ -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 @@ -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 { @@ -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()) + + // 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 { @@ -559,7 +577,7 @@ public class PostHogReplayIntegration( } view.phoneWindow?.let { window -> - window.touchEventInterceptors -= onTouchEventListener + touchInterceptors.remove(window)?.let { window.touchEventInterceptors -= it } } decorViews.remove(view) @@ -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 @@ -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) { @@ -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 } @@ -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) @@ -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() @@ -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 @@ -1802,6 +1864,7 @@ public class PostHogReplayIntegration( view.width, view.height, shouldVerifyMaskAlignment(view, drawState), + window.isSecure(), ) } ?: return null val viewId = System.identityHashCode(view) @@ -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) { @@ -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) @@ -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, 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 6ae399ba7..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,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, /** 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 99669f06f..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 @@ -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 @@ -52,6 +54,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 @@ -104,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) @@ -694,6 +698,116 @@ internal class PostHogReplayIntegrationTest { } } + @Test + fun `a touch on a secure window is not recorded`() { + val submits = AtomicInteger(0) + val config = configWithSampling(flagActive = true, samplingPasses = true) + 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 touch = { + val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 0f, 0f, 0) + window.callback.dispatchTouchEvent(event) + event.recycle() + awaitReplayExecutors() + } + + touch() + val submitsBeforeSecure = submits.get() + assertTrue(submitsBeforeSecure >= 1) + + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + + touch() + assertEquals(submitsBeforeSecure, submits.get()) + } finally { + sut.uninstall() + curtains.close() + controller.pause().stop().destroy() + } + } + + @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, @@ -3085,7 +3199,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) } @@ -3150,6 +3274,70 @@ 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() + RecordingShadowPixelCopy.reset() + RecordingShadowPixelCopy.onRequest = { it.eraseColor(Color.WHITE) } + try { + assertTrue(h.fx.sut.captureSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + 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) { + 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() + } + } + + @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) @@ -4406,6 +4594,146 @@ 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) + 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) + h.child.tag = null + h.child.text = "secret" + 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 `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) + 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() + } + } + + // 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) + whenever(it.hasEnded()).thenReturn(false) + } + @Test @Config(sdk = [26], shadows = [DrawSequenceShadowPixelCopy::class]) fun `legacy capture stays pinned when verification is enabled mid-capture`() {