From 222535c5846076635f8aa721f5addfeb71add01e Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:48:45 +0000 Subject: [PATCH] fix(replay): capture Jetpack Compose content in wireframe mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose draws its whole UI into a single AndroidComposeView that has no child Views, so the wireframe walk in `toWireframe()` bottomed out at the decor view and Compose screens recorded as a blank screen — with nothing in the logs to explain it, even with `debug = true`. Screenshot mode was the only mode that could see Compose, and it ships off by default. Compose content is now read from the semantics tree (the only structural description available without capturing pixels) and emitted as text, input and image wireframes, giving Compose apps the same wireframe experience View-based apps already get. Masking follows the existing rules: `postHogUnmask` wins, then `postHogMask` and passwords force masking, then `maskAllTextInputs`. Semantics can only be read on the main thread, so every Compose root under the decor view is read in a single hop per capture rather than one hop per root, and the mapping from semantics to wireframes runs on the capture thread. Screenshot mode still gives the highest fidelity, so the SDK now also logs that recommendation once when it detects Compose in wireframe mode. Generated-By: PostHog Code Task-Id: 537208f8-bc65-49f5-9463-e30d3aa58623 --- .changeset/compose-wireframe-capture.md | 5 + .../replay/PostHogReplayIntegration.kt | 191 +++++++++++++++- .../replay/internal/ComposeWireframe.kt | 188 ++++++++++++++++ .../replay/PostHogReplayIntegrationTest.kt | 208 ++++++++++++++++++ .../replay/internal/ComposeWireframeTest.kt | 182 +++++++++++++++ 5 files changed, 773 insertions(+), 1 deletion(-) create mode 100644 .changeset/compose-wireframe-capture.md create mode 100644 posthog-android/src/main/java/com/posthog/android/replay/internal/ComposeWireframe.kt create mode 100644 posthog-android/src/test/java/com/posthog/android/replay/internal/ComposeWireframeTest.kt diff --git a/.changeset/compose-wireframe-capture.md b/.changeset/compose-wireframe-capture.md new file mode 100644 index 000000000..2b1d3dd93 --- /dev/null +++ b/.changeset/compose-wireframe-capture.md @@ -0,0 +1,5 @@ +--- +"posthog-android": minor +--- + +Capture Jetpack Compose content in the default wireframe session replay mode. Compose draws its whole UI into a single `AndroidComposeView` with no child Views, so the wireframe walk previously bottomed out at the decor view and Compose screens recorded as a blank screen, with nothing in the logs to explain it. Compose content is now read from the semantics tree and emitted as text, input, and image wireframes, honouring `maskAllTextInputs`, passwords, and the `postHogMask`/`postHogUnmask` modifiers. Screenshot mode (`sessionReplayConfig.screenshot = true`) still gives the highest fidelity, and the SDK now logs that recommendation once when it detects Compose in wireframe mode. 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 ef35d54ba..576e54d6c 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 @@ -23,6 +23,7 @@ import android.graphics.drawable.VectorDrawable import android.os.Build import android.os.Handler import android.os.HandlerThread +import android.os.Looper import android.text.InputType import android.util.TypedValue import android.view.Gravity @@ -49,10 +50,13 @@ import android.widget.Spinner import android.widget.Switch import android.widget.TextView import androidx.compose.ui.node.RootForTest +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.getAllSemanticsNodes +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.state.ToggleableState import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import com.posthog.PostHogIntegration @@ -66,10 +70,13 @@ import com.posthog.android.internal.screenSize import com.posthog.android.internal.webpBase64 import com.posthog.android.replay.PostHogMaskModifier.PostHogReplayMask import com.posthog.android.replay.PostHogMaskModifier.PostHogReplayUnmask +import com.posthog.android.replay.internal.ComposeRole +import com.posthog.android.replay.internal.ComposeSemanticsNode import com.posthog.android.replay.internal.NextDrawListener.Companion.onNextDraw import com.posthog.android.replay.internal.ViewTreeSnapshotStatus import com.posthog.android.replay.internal.isAlive import com.posthog.android.replay.internal.isAliveAndAttachedToWindow +import com.posthog.android.replay.internal.toWireframes import com.posthog.internal.PostHogSessionManager import com.posthog.internal.PostHogThreadFactory import com.posthog.internal.replay.PostHogSessionReplayHandler @@ -101,6 +108,8 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference public class PostHogReplayIntegration( private val context: Context, @@ -728,7 +737,13 @@ public class PostHogReplayIntegration( window, ) ?: return false } else { - view.toWireframe() ?: return false + // read up front, in one main-thread hop, so the walk itself stays on this thread + composeSemantics = composeSemanticsReader(view) + try { + view.toWireframe() ?: return false + } finally { + composeSemantics = emptyMap() + } } // if the decorView has no backgroundColor, we use the theme color @@ -1183,6 +1198,164 @@ public class PostHogReplayIntegration( return isComposeAvailable && this.javaClass.name.contains(ANDROID_COMPOSE_VIEW) } + private val loggedComposeWireframeMode = AtomicBoolean(false) + + /** + * Compose draws its whole UI into a single `AndroidComposeView` that has no child Views, so the + * wireframe walk in [toWireframe] bottoms out there and the player is left with nothing but the + * window background — a blank screen. The semantics tree is the only structural description of + * the content available without capturing pixels, so wireframe mode is built from it. + * + * Reads every Compose root under the decor view at once, keyed by root, because semantics can + * only be read on the main thread and a screen can hold many roots (a `ComposeView` per list + * row, for instance) — one hop per capture instead of one per root. + * + * Compose is a `compileOnly` dependency, so its classes are absent unless the host app uses it. + * All Compose access goes through this seam to keep that constraint in one place, and so tests + * can drive the wireframe mapping without a Compose runtime on the classpath. + */ + internal var composeSemanticsReader: (View) -> Map> = { decorView -> + if (isComposeAvailable) { + decorView.readComposeSemanticsOnMainThread() + } else { + emptyMap() + } + } + + // Set and cleared within a single [generateSnapshot] call, so the [toWireframe] walk it drives + // can look up each Compose root's nodes without threading them through the recursion. Never + // read outside that call, so it needs no synchronization. + private var composeSemantics: Map> = emptyMap() + + /** + * Compose used to be captured by screenshot mode only, and silently produced blank recordings + * otherwise, with nothing in the logs to explain it. Logged once per integration so a developer + * running with `debug = true` can tell wireframe mode apart from a broken recording. + */ + private fun logComposeWireframeModeOnce() { + if (loggedComposeWireframeMode.compareAndSet(false, true)) { + config.logger.log( + "Session Replay detected Jetpack Compose content and is capturing it as a wireframe " + + "from the semantics tree. Styling and content without semantics (custom Canvas " + + "drawing, images, icons without a contentDescription) is not captured. For a " + + "full fidelity recording set sessionReplayConfig.screenshot = true, but note " + + "that screenshots may contain sensitive information.", + ) + } + } + + private fun View.readComposeSemanticsOnMainThread(): Map> { + // compose requires the semantics tree to be read on the main thread + // see https://github.com/PostHog/posthog-android/issues/203 + if (Looper.myLooper() == mainHandler.mainLooper) { + return readComposeSemantics() + } + + // set before the countDown so awaiting the latch publishes the map safely + val semantics = AtomicReference>>(emptyMap()) + val latch = CountDownLatch(1) + + mainHandler.handler.post { + try { + semantics.set(readComposeSemantics()) + } finally { + latch.countDown() + } + } + + try { + // await for 1s max + latch.await(1000, TimeUnit.MILLISECONDS) + } catch (e: Throwable) { + config.logger.log("Session Replay reading Compose semantics failed: $e") + } + + return semantics.get() + } + + private fun View.readComposeSemantics(): Map> { + val semantics = mutableMapOf>() + + fun walk(view: View) { + // a hidden subtree is skipped by the wireframe walk anyway + if (view.visibility != View.VISIBLE) { + return + } + if (view.isComposeView()) { + logComposeWireframeModeOnce() + semantics[view] = view.readSemanticsNodes() + // fall through: a Compose root's View children are the interop views, which may + // host Compose roots of their own + } + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + walk(view.getChildAt(i) ?: continue) + } + } + } + walk(this) + + return semantics + } + + private fun View.readSemanticsNodes(): List { + return try { + val semanticsOwner = + (this as? RootForTest)?.semanticsOwner ?: run { + config.logger.log("View is not a RootForTest: $this") + return emptyList() + } + + semanticsOwner.getAllSemanticsNodes(true).map { it.toComposeSemanticsNode() } + } catch (e: Throwable) { + // swallow possible errors due to compose versioning, etc + config.logger.log("Session Replay reading Compose semantics (main thread) failed: $e") + emptyList() + } + } + + private fun SemanticsNode.toComposeSemanticsNode(): ComposeSemanticsNode { + val semantics = this.config + val text = + semantics.getOrNull(SemanticsProperties.Text) + ?.joinToString(separator = " ") { it.text } + ?: semantics.getOrNull(SemanticsProperties.EditableText)?.text + val checked = + when (semantics.getOrNull(SemanticsProperties.ToggleableState)) { + ToggleableState.On -> true + ToggleableState.Off -> false + else -> semantics.getOrNull(SemanticsProperties.Selected) + } + + return ComposeSemanticsNode( + id = id, + bounds = boundsInWindow.toRect(), + text = text, + isEditable = semantics.contains(SemanticsProperties.EditableText), + isPassword = semantics.contains(SemanticsProperties.Password), + contentDescription = + semantics.getOrNull(SemanticsProperties.ContentDescription) + ?.joinToString(separator = " "), + role = semantics.getOrNull(SemanticsProperties.Role).toComposeRole(), + checked = checked, + maskForced = hasActiveModifier(PostHogReplayMask), + unmaskForced = hasActiveModifier(PostHogReplayUnmask), + ) + } + + private fun Role?.toComposeRole(): ComposeRole { + return when (this) { + Role.Button -> ComposeRole.Button + Role.Checkbox -> ComposeRole.Checkbox + Role.Switch -> ComposeRole.Switch + Role.RadioButton -> ComposeRole.RadioButton + Role.Tab -> ComposeRole.Tab + Role.Image -> ComposeRole.Image + // roles added in newer Compose versions still render as text + else -> ComposeRole.None + } + } + private val isComposeAvailable by lazy(LazyThreadSafetyMode.PUBLICATION) { try { Class.forName(ANDROID_COMPOSE_VIEW_CLASS_NAME) @@ -1613,6 +1786,22 @@ public class PostHogReplayIntegration( } val children = mutableListOf() + val composeNodes = if (composeSemantics.isEmpty()) emptyList() else composeSemantics[view].orEmpty() + if (composeNodes.isNotEmpty()) { + // wireframe positions are absolute, so the semantics tree is flattened into a + // single level of children rather than mirrored as a nested tree + children.addAll( + composeNodes.toWireframes( + hostViewId = viewId, + parentId = viewId, + density = screenDensity, + maskAllTextInputs = config.sessionReplayConfig.maskAllTextInputs, + ancestorUnmasked = isUnmasked, + ), + ) + } + // an AndroidComposeView is a ViewGroup too, its children are the interop views + // (AndroidView, FragmentContainerView, etc) if (view is ViewGroup && view.childCount > 0) { for (i in 0 until view.childCount) { val viewChild = view.getChildAt(i) ?: continue diff --git a/posthog-android/src/main/java/com/posthog/android/replay/internal/ComposeWireframe.kt b/posthog-android/src/main/java/com/posthog/android/replay/internal/ComposeWireframe.kt new file mode 100644 index 000000000..c1d82eb53 --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/replay/internal/ComposeWireframe.kt @@ -0,0 +1,188 @@ +package com.posthog.android.replay.internal + +import android.graphics.Rect +import com.posthog.android.internal.densityValue +import com.posthog.internal.replay.RRStyle +import com.posthog.internal.replay.RRWireframe + +/** + * The subset of a Compose `SemanticsNode` the wireframe renderer needs. + * + * Semantics can only be read on the main thread, so reading the tree and turning it into + * wireframes are two separate steps: the reader hops to the main thread and fills these in, + * the mapping below runs on the capture thread. Keeping the mapping off the Compose types + * also means the masking rules can be tested without a Compose runtime on the classpath. + */ +internal data class ComposeSemanticsNode( + /** `SemanticsNode.id`, stable across recompositions for the same layout node. */ + val id: Int, + /** `boundsInWindow`, in pixels. */ + val bounds: Rect, + val text: String? = null, + val isEditable: Boolean = false, + val isPassword: Boolean = false, + val contentDescription: String? = null, + val role: ComposeRole = ComposeRole.None, + val checked: Boolean? = null, + /** `postHogMask` is active on this node or an ancestor. */ + val maskForced: Boolean = false, + /** `postHogUnmask` is active on this node or an ancestor, and wins over everything else. */ + val unmaskForced: Boolean = false, +) + +/** + * Mirror of the Compose `Role`s that map onto a wireframe type. Only the roles that exist in + * every supported Compose version are listed — the reader falls back to [None] for the rest, + * which still renders as text. + */ +internal enum class ComposeRole { + None, + Button, + Checkbox, + Switch, + RadioButton, + Tab, + Image, +} + +/** + * Converts a flattened (merged) Compose semantics tree into wireframes. + * + * Compose draws its whole UI into a single `AndroidComposeView` with no child Views, so without + * this the wireframe tree bottoms out at that view and the player has nothing but a background + * colour to render. Wireframe positions are absolute, so the nodes are emitted as a flat list of + * children rather than a nested tree. + * + * Nodes that carry no visual information (layout containers, nodes with empty bounds) are skipped: + * wireframe mode only ships what the SDK understands, and an empty rectangle would just add noise. + */ +internal fun List.toWireframes( + hostViewId: Int, + parentId: Int, + density: Float, + maskAllTextInputs: Boolean, + ancestorUnmasked: Boolean = false, +): List { + val wireframes = mutableListOf() + + for (node in this) { + if (node.bounds.width() <= 0 || node.bounds.height() <= 0) { + continue + } + + // kept in sync with the screenshot path (findMaskableComposeWidgets) and with the View path + // (shouldMaskTextView): postHogUnmask wins over everything, then postHogMask and passwords + // force masking, then the maskAllTextInputs config applies. Images need no rule of their + // own — semantics carries no pixels, so they are never captured either way. + val unmasked = ancestorUnmasked || node.unmaskForced + val maskText = !unmasked && (node.maskForced || node.isPassword || maskAllTextInputs) + + val text = node.text?.takeUnless { it.isEmpty() }?.let { if (maskText) it.mask() else it } + val isImage = node.role == ComposeRole.Image || (text == null && node.contentDescription != null) + + var type: String? = null + var inputType: String? = null + var wireframeText: String? = null + var value: Any? = null + var label: String? = null + var checked: Boolean? = null + + when { + isImage -> { + // no base64, so the player draws its image placeholder — the same thing a masked + // ImageView produces on the View path + type = "image" + } + + node.role == ComposeRole.Button -> { + type = "input" + inputType = "button" + value = text + } + + node.role == ComposeRole.Checkbox -> { + type = "input" + inputType = "checkbox" + label = text + checked = node.checked + } + + node.role == ComposeRole.RadioButton || node.role == ComposeRole.Tab -> { + type = "input" + inputType = "radio" + label = text + checked = node.checked + } + + node.role == ComposeRole.Switch -> { + type = "input" + inputType = "toggle" + label = text + checked = node.checked + } + + node.isEditable -> { + type = "input" + inputType = "text_area" + value = text + } + + text != null -> { + type = "text" + wireframeText = text + } + + else -> { + // no text, no image, no role: nothing to draw + continue + } + } + + val style = RRStyle() + if (type == "input" && inputType == "button") { + style.borderWidth = 1 + style.borderColor = "#000000" + } + if (type == "text") { + style.verticalAlign = "center" + style.horizontalAlign = "left" + } + + wireframes.add( + RRWireframe( + id = composeWireframeId(hostViewId, node.id), + x = node.bounds.left.densityValue(density), + y = node.bounds.top.densityValue(density), + width = node.bounds.width().densityValue(density), + height = node.bounds.height().densityValue(density), + type = type, + inputType = inputType, + text = wireframeText, + value = value, + label = label, + checked = checked, + style = style, + parentId = parentId, + ), + ) + } + + return wireframes +} + +/** + * Semantics ids are small integers scoped to their composition root, while View wireframe ids come + * from `System.identityHashCode`. Snapshots are diffed purely by id, so the semantics id is mixed + * with the host view's id to stay unique within a snapshot while remaining stable across snapshots. + */ +internal fun composeWireframeId( + hostViewId: Int, + semanticsId: Int, +): Int { + // golden-ratio constant, spreads the small semantics ids over the whole int range + return hostViewId xor (semanticsId * -0x61c88647) +} + +private fun String.mask(): String { + return "*".repeat(length) +} 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 294d750b8..e26847f47 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 @@ -3,12 +3,14 @@ package com.posthog.android.replay import android.app.Activity import android.content.Context import android.graphics.Bitmap +import android.graphics.Rect import android.graphics.drawable.BitmapDrawable import android.os.Looper import android.view.MotionEvent import android.view.SurfaceView import android.view.View import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.Window import android.widget.FrameLayout import android.widget.TextView @@ -21,6 +23,8 @@ import com.posthog.android.API_KEY import com.posthog.android.PostHogAndroidConfig import com.posthog.android.createPostHogFake import com.posthog.android.internal.MainHandler +import com.posthog.android.replay.internal.ComposeRole +import com.posthog.android.replay.internal.ComposeSemanticsNode import com.posthog.android.replay.internal.NextDrawListener import com.posthog.android.replay.internal.ViewTreeSnapshotStatus import com.posthog.internal.EndpointSpec @@ -35,6 +39,11 @@ import com.posthog.internal.PostHogQueue import com.posthog.internal.PostHogQueueInterface import com.posthog.internal.PostHogRemoteConfig import com.posthog.internal.PostHogSessionManager +import com.posthog.internal.replay.RREvent +import com.posthog.internal.replay.RRFullSnapshotEvent +import com.posthog.internal.replay.RRIncrementalMutationData +import com.posthog.internal.replay.RRIncrementalSnapshotEvent +import com.posthog.internal.replay.RRWireframe import curtains.DispatchState import org.junit.Rule import org.junit.rules.TemporaryFolder @@ -1475,6 +1484,205 @@ internal class PostHogReplayIntegrationTest { } } + // Compose draws its whole UI into a single AndroidComposeView that has no child Views. The + // name matters: the integration identifies Compose roots by class name. + private class FakeAndroidComposeView(context: Context) : ViewGroup(context) { + // interop children fill the root, which is all these tests need from layout + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + for (i in 0 until childCount) { + getChildAt(i).layout(0, 0, r - l, b - t) + } + } + } + + private fun wireframeFixture(): Pair { + val fx = + createIntegrationWithRealQueue( + flagActive = true, + hasFetched = true, + integrationContext = ApplicationProvider.getApplicationContext(), + ) + // wireframe mode is the default, spelled out because it is what these tests are about + fx.config.sessionReplayConfig.screenshot = false + val fake = PostHogFake() + fx.sut.install(fake) + fx.sut.start(resumeCurrent = true) + return fx to fake + } + + private class ComposeWindow(val decorView: View, val composeView: View) + + // Lays out a Compose root inside the activity window and registers the decor view with the + // integration so generateSnapshot() will walk it. + private fun composeWindow(fx: RealQueueFixture): ComposeWindow { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val composeView = FakeAndroidComposeView(activity) + activity.setContentView(composeView, ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)) + shadowOf(Looper.getMainLooper()).idle() + + val decorView = activity.window.decorView + makeWindowVisible(decorView) + decorView.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY), + ) + decorView.layout(0, 0, 1080, 1920) + fx.sut.decorViews[decorView] = ViewTreeSnapshotStatus(mock()) + return ComposeWindow(decorView, composeView) + } + + // stands in for the Compose semantics tree, which needs a Compose runtime to build + private fun ComposeWindow.fakeSemanticsReader(vararg nodes: ComposeSemanticsNode): (View) -> Map> { + return { mapOf(composeView to nodes.toList()) } + } + + // The decor view also holds the window decorations (action bar title and so on), so only the + // children of the Compose root are the content under test. + private fun PostHogFake.composeWireframes(window: ComposeWindow): List { + val composeViewId = System.identityHashCode(window.composeView) + return snapshotWireframes().filter { it.parentId == composeViewId } + } + + private fun PostHogFake.snapshotEvents(): List { + @Suppress("UNCHECKED_CAST") + return properties?.get("\$snapshot_data") as? List ?: emptyList() + } + + private fun PostHogFake.snapshotWireframes(): List { + val fullSnapshot = snapshotEvents().filterIsInstance().single() + + @Suppress("UNCHECKED_CAST") + val roots = (fullSnapshot.data as Map)["wireframes"] as List + val flattened = mutableListOf() + + fun flatten(wireframes: List) { + wireframes.forEach { + flattened.add(it) + it.childWireframes?.let(::flatten) + } + } + flatten(roots) + return flattened + } + + @Test + fun `wireframe mode captures compose content from the semantics tree`() { + val (fx, fake) = wireframeFixture() + try { + val window = composeWindow(fx) + fx.sut.composeSemanticsReader = + window.fakeSemanticsReader( + ComposeSemanticsNode(id = 1, bounds = Rect(0, 0, 500, 100), text = "Welcome back"), + ComposeSemanticsNode( + id = 2, + bounds = Rect(0, 200, 500, 300), + text = "Sign in", + role = ComposeRole.Button, + ), + ) + + fx.sut.generateSnapshot(WeakReference(window.decorView), WeakReference(mock())) + + val wireframes = fake.composeWireframes(window) + assertEquals("************", wireframes.single { it.type == "text" }.text) + assertEquals("*******", wireframes.single { it.inputType == "button" }.value) + } finally { + fx.sut.uninstall() + } + } + + @Test + fun `wireframe mode without compose content produces an empty screen`() { + // Documents the bug this replaces: a Compose window has no child Views, so with nothing + // read from the semantics tree the snapshot carries no content at all and the player + // renders a blank screen. + val (fx, fake) = wireframeFixture() + try { + val window = composeWindow(fx) + fx.sut.composeSemanticsReader = { emptyMap() } + + fx.sut.generateSnapshot(WeakReference(window.decorView), WeakReference(mock())) + + assertTrue(fake.composeWireframes(window).isEmpty()) + } finally { + fx.sut.uninstall() + } + } + + @Test + fun `each compose root contributes its own wireframes`() { + // a screen can hold many Compose roots (a ComposeView per list row); each root's content + // must land under that root and keep its own ids + val (fx, fake) = wireframeFixture() + try { + val window = composeWindow(fx) + val secondRoot = FakeAndroidComposeView(window.composeView.context) + (window.composeView as ViewGroup).addView(secondRoot, ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)) + window.decorView.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY), + ) + window.decorView.layout(0, 0, 1080, 1920) + fx.sut.composeSemanticsReader = { + mapOf( + window.composeView to listOf(ComposeSemanticsNode(id = 1, bounds = Rect(0, 0, 500, 100), text = "first")), + secondRoot to listOf(ComposeSemanticsNode(id = 1, bounds = Rect(0, 200, 500, 300), text = "second")), + ) + } + + fx.sut.generateSnapshot(WeakReference(window.decorView), WeakReference(mock())) + + val first = fake.composeWireframes(window).single { it.type == "text" } + val second = + fake.snapshotWireframes() + .single { it.parentId == System.identityHashCode(secondRoot) && it.type == "text" } + assertNotEquals(first.id, second.id, "the same semantics id in two roots must not collide") + assertEquals(0, first.y) + assertEquals(200, second.y) + } finally { + fx.sut.uninstall() + } + } + + @Test + fun `compose wireframes keep their ids across snapshots`() { + // ids are the only thing incremental mutations are matched on, so an unchanged node must + // not be re-added on every frame + val (fx, fake) = wireframeFixture() + try { + val window = composeWindow(fx) + fx.sut.composeSemanticsReader = + window.fakeSemanticsReader(ComposeSemanticsNode(id = 1, bounds = Rect(0, 0, 500, 100), text = "Welcome back")) + + fx.sut.generateSnapshot(WeakReference(window.decorView), WeakReference(mock())) + val firstId = fake.composeWireframes(window).single().id + assertEquals(1, fake.captures) + + fx.sut.generateSnapshot(WeakReference(window.decorView), WeakReference(mock())) + + assertEquals(1, fake.captures, "an unchanged compose tree must not emit mutations") + + fx.sut.composeSemanticsReader = + window.fakeSemanticsReader(ComposeSemanticsNode(id = 1, bounds = Rect(0, 0, 500, 100), text = "Welcome again")) + fx.sut.generateSnapshot(WeakReference(window.decorView), WeakReference(mock())) + + val updates = + fake.snapshotEvents().filterIsInstance() + .single() + .let { it.data as RRIncrementalMutationData } + .updates.orEmpty() + assertEquals(listOf(firstId), updates.map { it.wireframe.id }) + } finally { + fx.sut.uninstall() + } + } + // isOnlyAnimationRedraw is private; read it via field reflection (which, unlike // getDeclaredMethod, does not force resolution of the class's Compose-referencing methods). private fun isOnlyAnimationRedraw(sut: PostHogReplayIntegration): Boolean { diff --git a/posthog-android/src/test/java/com/posthog/android/replay/internal/ComposeWireframeTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/internal/ComposeWireframeTest.kt new file mode 100644 index 000000000..0e2eefecf --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/replay/internal/ComposeWireframeTest.kt @@ -0,0 +1,182 @@ +package com.posthog.android.replay.internal + +import android.graphics.Rect +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +internal class ComposeWireframeTest { + private fun node( + id: Int = 1, + bounds: Rect = Rect(0, 0, 200, 100), + text: String? = null, + isEditable: Boolean = false, + isPassword: Boolean = false, + contentDescription: String? = null, + role: ComposeRole = ComposeRole.None, + checked: Boolean? = null, + maskForced: Boolean = false, + unmaskForced: Boolean = false, + ) = ComposeSemanticsNode( + id = id, + bounds = bounds, + text = text, + isEditable = isEditable, + isPassword = isPassword, + contentDescription = contentDescription, + role = role, + checked = checked, + maskForced = maskForced, + unmaskForced = unmaskForced, + ) + + private fun List.wireframes( + maskAllTextInputs: Boolean = true, + density: Float = 1f, + ancestorUnmasked: Boolean = false, + ) = toWireframes( + hostViewId = HOST_ID, + parentId = HOST_ID, + density = density, + maskAllTextInputs = maskAllTextInputs, + ancestorUnmasked = ancestorUnmasked, + ) + + @Test + fun `text node becomes a text wireframe`() { + val wireframe = listOf(node(text = "Welcome back")).wireframes(maskAllTextInputs = false).single() + + assertEquals("text", wireframe.type) + assertEquals("Welcome back", wireframe.text) + assertEquals(HOST_ID, wireframe.parentId) + } + + @Test + fun `text is masked by default`() { + // parity with the View path: maskAllTextInputs replaces the text with same-length asterisks + val wireframe = listOf(node(text = "Welcome back")).wireframes().single() + + assertEquals("************", wireframe.text) + } + + @Test + fun `passwords are masked even when text masking is off`() { + val wireframe = listOf(node(text = "hunter2", isPassword = true, isEditable = true)).wireframes(maskAllTextInputs = false).single() + + assertEquals("*******", wireframe.value) + } + + @Test + fun `postHogMask forces masking when text masking is off`() { + val wireframe = listOf(node(text = "secret", maskForced = true)).wireframes(maskAllTextInputs = false).single() + + assertEquals("******", wireframe.text) + } + + @Test + fun `postHogUnmask wins over the mask config`() { + val wireframe = listOf(node(text = "public", maskForced = true, unmaskForced = true)).wireframes().single() + + assertEquals("public", wireframe.text) + } + + @Test + fun `an unmasked ancestor view unmasks the compose content below it`() { + val wireframe = listOf(node(text = "public")).wireframes(ancestorUnmasked = true).single() + + assertEquals("public", wireframe.text) + } + + @Test + fun `editable text becomes a text input`() { + val wireframe = listOf(node(text = "me@example.com", isEditable = true)).wireframes(maskAllTextInputs = false).single() + + assertEquals("input", wireframe.type) + assertEquals("text_area", wireframe.inputType) + assertEquals("me@example.com", wireframe.value) + assertNull(wireframe.text) + } + + @Test + fun `button role becomes a button input carrying its label`() { + val wireframe = listOf(node(text = "Sign in", role = ComposeRole.Button)).wireframes(maskAllTextInputs = false).single() + + assertEquals("input", wireframe.type) + assertEquals("button", wireframe.inputType) + assertEquals("Sign in", wireframe.value) + } + + @Test + fun `toggleable roles carry their checked state`() { + val checkbox = + listOf( + node(text = "Remember me", role = ComposeRole.Checkbox, checked = true), + ).wireframes(maskAllTextInputs = false).single() + val switch = + listOf( + node(text = "Dark mode", role = ComposeRole.Switch, checked = false), + ).wireframes(maskAllTextInputs = false).single() + val radio = listOf(node(role = ComposeRole.RadioButton, checked = true)).wireframes(maskAllTextInputs = false).single() + + assertEquals("checkbox", checkbox.inputType) + assertEquals(true, checkbox.checked) + assertEquals("Remember me", checkbox.label) + assertEquals("toggle", switch.inputType) + assertEquals(false, switch.checked) + assertEquals("radio", radio.inputType) + assertEquals(true, radio.checked) + } + + @Test + fun `images become a placeholder because semantics carries no pixels`() { + val described = listOf(node(contentDescription = "Profile picture")).wireframes().single() + val roled = listOf(node(role = ComposeRole.Image)).wireframes(maskAllTextInputs = false).single() + + assertEquals("image", described.type) + assertNull(described.base64) + assertEquals("image", roled.type) + assertNull(roled.base64) + } + + @Test + fun `nodes without text image or role are skipped`() { + // layout containers carry no visual information, an empty rectangle would only add noise + assertTrue(listOf(node()).wireframes().isEmpty()) + } + + @Test + fun `nodes with empty bounds are skipped`() { + assertTrue(listOf(node(text = "hi", bounds = Rect(0, 0, 0, 0))).wireframes().isEmpty()) + } + + @Test + fun `bounds are converted from pixels to density independent values`() { + val wireframe = + listOf(node(text = "hi", bounds = Rect(20, 40, 220, 140))) + .wireframes(density = 2f) + .single() + + assertEquals(10, wireframe.x) + assertEquals(20, wireframe.y) + assertEquals(100, wireframe.width) + assertEquals(50, wireframe.height) + } + + @Test + fun `wireframe ids are stable per semantics id and differ across hosts`() { + // snapshots are diffed by id only, so the same node must keep its id across frames + // while two Compose roots in one window must not collide + assertEquals(composeWireframeId(HOST_ID, 7), composeWireframeId(HOST_ID, 7)) + assertNotEquals(composeWireframeId(HOST_ID, 7), composeWireframeId(HOST_ID, 8)) + assertNotEquals(composeWireframeId(HOST_ID, 7), composeWireframeId(HOST_ID + 1, 7)) + } + + private companion object { + const val HOST_ID = 42 + } +}