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 + } +}