Skip to content
Open
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/compose-wireframe-warning.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,7 @@ public class PostHogReplayIntegration(
status.drawState,
) ?: return false
} else {
warnIfComposeWireframe(view, status.drawState)
view.toWireframe() ?: return false
}

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/**
Expand All @@ -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,
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1843,6 +1843,114 @@ internal class PostHogReplayIntegrationTest {
}
}

private fun wireframeFixture(
messages: MutableList<String>,
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<String>())
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<FrameLayout>(android.R.id.content)
.addView(FakeAndroidComposeView(activity))
fx.sut.decorViews[decorView] = ViewTreeSnapshotStatus(mock<NextDrawListener>())

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<String>())
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<FrameLayout>(android.R.id.content)
.addView(TextView(activity))
fx.sut.decorViews[decorView] = ViewTreeSnapshotStatus(mock<NextDrawListener>())

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<String>())
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<FrameLayout>(android.R.id.content)
.addView(FakeAndroidComposeView(activity))
fx.sut.decorViews[decorView] = ViewTreeSnapshotStatus(mock<NextDrawListener>())

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

Expand Down
Loading