diff --git a/.changeset/compose-wireframe-warning.md b/.changeset/compose-wireframe-warning.md new file mode 100644 index 000000000..bd2bc010b --- /dev/null +++ b/.changeset/compose-wireframe-warning.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Fix: session replay now tells you why a Jetpack Compose recording is blank. Wireframe capture, which is the default (`sessionReplayConfig.screenshot = false`), only walks classic Android View types, so a Compose window produces an almost empty wireframe tree that plays back as a gray screen. The SDK now logs one warning when it finds a Compose root while wireframe capture is on, and the warning names `sessionReplayConfig.screenshot = true` as the fix. The KDoc on `screenshot`, `maskAllTextInputs` and `maskAllImages` also states this. 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 1ebc1d138..fffa37cbc 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 @@ -734,6 +734,7 @@ public class PostHogReplayIntegration( status.drawState, ) ?: return false } else { + warnIfComposeWireframe(view, status.drawState) view.toWireframe() ?: return false } @@ -1287,6 +1288,44 @@ public class PostHogReplayIntegration( view.isComposeRooted(drawState) } + // Fires once per process: repeating it on every snapshot would flood logcat, and one line + // is enough to point the developer at the option that fixes the recording. Read before the + // Compose check too, so a warned process never pays for the tree walk again. + private val composeWireframeWarningFired = AtomicBoolean(false) + + // Wireframes are built from classic Android View types only, so whatever Compose draws + // comes back as an empty box that plays back blank, up to the whole screen when Compose + // draws all of it. Say so, because the capture itself keeps succeeding and the developer + // gets no other signal. + private fun warnIfComposeWireframe( + view: View, + drawState: WindowDrawState, + ) { + // The logger drops the message while debug logging is off, and PostHog.debug(true) can + // turn it on at any point, so the once-per-process budget must not be spent on a line + // nobody receives. Reading it up front also skips the Compose check while debug is off. + if (composeWireframeWarningFired.get() || !config.logger.isEnabled()) { + return + } + // Never block the capture thread for a log line: off the main thread the verdict is + // resolved there without waiting and read from the cache on the next snapshot. + val rooted = + drawState.composeRooted ?: if (Looper.myLooper() == mainHandler.handler.looper) { + view.isComposeRooted(drawState) + } else { + mainHandler.handler.post { view.isComposeRooted(drawState) } + return + } + if (rooted && composeWireframeWarningFired.compareAndSet(false, true)) { + config.logger.log( + "Session Replay found Jetpack Compose content, but wireframe capture is on. " + + "Wireframes only cover classic Android Views, so Compose content records " + + "blank; on a fully Compose screen that is the whole recording. " + + "Set sessionReplayConfig.screenshot = true to record Compose content.", + ) + } + } + private fun View.isComposeRooted(drawState: WindowDrawState): Boolean { drawState.composeRooted?.let { return it } if (!isComposeAvailable) { 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 c01acac88..17c2de13c 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 @@ -12,13 +12,15 @@ public class PostHogSessionReplayConfig @JvmOverloads constructor( /** - * Enable masking of all text and text input fields - * Defaults to true + * Enable masking of all text and text input fields. + * The mask applies to wireframe capture and to screenshot capture. + * Defaults to true. */ public var maskAllTextInputs: Boolean = true, /** - * Enable masking of all images to a placeholder - * Defaults to true + * Enable masking of all images to a placeholder. + * The mask applies to wireframe capture and to screenshot capture. + * Defaults to true. */ public var maskAllImages: Boolean = true, /** @@ -33,9 +35,12 @@ public class PostHogSessionReplayConfig */ public var drawableConverter: PostHogDrawableConverter? = null, /** - * 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. + * Capture each frame as a masked screenshot instead of a wireframe. + * Defaults to false, which captures the views on the screen as a wireframe. + * A wireframe only covers classic Android View types, so a screen that Jetpack Compose + * draws records as a blank screen. Set this option to true for Jetpack Compose apps. + * The mask options still apply to a screenshot, but a screenshot can show sensitive + * information that the mask options do not cover. Use with caution. */ 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 c7c7c0fa9..57eff8eff 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 @@ -1843,6 +1843,114 @@ internal class PostHogReplayIntegrationTest { } } + private fun wireframeFixture( + messages: MutableList, + loggerEnabled: () -> Boolean = { true }, + ): RealQueueFixture { + val fx = + createIntegrationWithRealQueue( + flagActive = true, + hasFetched = true, + integrationContext = ApplicationProvider.getApplicationContext(), + ) + fx.config.sessionReplayConfig.screenshot = false + // Drops the message while disabled, exactly like PostHogAndroidLogger does when + // config.debug is off. + fx.config.logger = + object : PostHogLogger { + override fun log(message: String) { + if (isEnabled()) { + messages.add(message) + } + } + + override fun isEnabled(): Boolean = loggerEnabled() + } + fx.sut.install(PostHogFake()) + fx.sut.start(resumeCurrent = true) + return fx + } + + @Test + fun `wireframe capture warns once about a compose rooted window`() { + // A wireframe only covers classic View types, so a Compose window records as a blank + // screen while every capture still reports success. One log line must name the option + // that fixes it, and it must not repeat on every snapshot. + val messages = Collections.synchronizedList(mutableListOf()) + val fx = wireframeFixture(messages) + try { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val decorView = activity.window.decorView + makeWindowVisible(decorView) + activity.findViewById(android.R.id.content) + .addView(FakeAndroidComposeView(activity)) + fx.sut.decorViews[decorView] = ViewTreeSnapshotStatus(mock()) + + repeat(2) { + fx.sut.generateSnapshot(WeakReference(decorView), WeakReference(activity.window)) + } + + assertEquals(1, messages.count { it.contains("sessionReplayConfig.screenshot = true") }) + } finally { + fx.sut.uninstall() + } + } + + @Test + fun `wireframe capture stays quiet for a classic view window`() { + // Control for the Compose warning: a window that the wireframe walk can render must + // not tell the customer to switch capture mode. + val messages = Collections.synchronizedList(mutableListOf()) + val fx = wireframeFixture(messages) + try { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val decorView = activity.window.decorView + makeWindowVisible(decorView) + activity.findViewById(android.R.id.content) + .addView(TextView(activity)) + fx.sut.decorViews[decorView] = ViewTreeSnapshotStatus(mock()) + + fx.sut.generateSnapshot(WeakReference(decorView), WeakReference(activity.window)) + + assertFalse(messages.any { it.contains("sessionReplayConfig.screenshot = true") }) + } finally { + fx.sut.uninstall() + } + } + + @Test + fun `wireframe compose warning is not spent while debug logging is off`() { + // Debug logging is off by default, so the first Compose snapshots happen with the sink + // dropping everything. The once-per-process guard must not be spent on those, otherwise + // turning debug on later could never surface the warning. + val messages = Collections.synchronizedList(mutableListOf()) + val debugEnabled = AtomicBoolean(false) + val fx = wireframeFixture(messages) { debugEnabled.get() } + try { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val decorView = activity.window.decorView + makeWindowVisible(decorView) + activity.findViewById(android.R.id.content) + .addView(FakeAndroidComposeView(activity)) + fx.sut.decorViews[decorView] = ViewTreeSnapshotStatus(mock()) + + fx.sut.generateSnapshot(WeakReference(decorView), WeakReference(activity.window)) + assertFalse(messages.any { it.contains("sessionReplayConfig.screenshot = true") }) + + debugEnabled.set(true) + repeat(2) { + fx.sut.generateSnapshot(WeakReference(decorView), WeakReference(activity.window)) + } + + assertEquals(1, messages.count { it.contains("sessionReplayConfig.screenshot = true") }) + } finally { + fx.sut.uninstall() + } + } + private class OnceThrowingChildFrameLayout(context: Context) : FrameLayout(context) { private var thrown = false