Skip to content

Commit c654911

Browse files
committed
PANA-8618: Compose pixel-fallback capture
1 parent ba01fb6 commit c654911

21 files changed

Lines changed: 711 additions & 57 deletions

File tree

dd-sdk-android-internal/api/apiSurface

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ interface com.datadog.android.internal.sessionreplay.composition.CompositionIden
323323
fun shapeWireframe(CapturedIdentity): CapturedIdentity
324324
fun textWireframe(CapturedIdentity): CapturedIdentity
325325
fun placeholderWireframe(CapturedIdentity): CapturedIdentity
326+
fun imageWireframe(CapturedIdentity): CapturedIdentity
326327
interface com.datadog.android.internal.system.BuildSdkVersionProvider
327328
val version: Int
328329
val isAtLeastN: Boolean

dd-sdk-android-internal/api/dd-sdk-android-internal.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,7 @@ public final class com/datadog/android/internal/sessionreplay/composition/Captur
10461046
public abstract interface class com/datadog/android/internal/sessionreplay/composition/CompositionIdentityFactory {
10471047
public abstract fun composeHost (Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;Ljava/lang/String;)Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;
10481048
public abstract fun composeNode (Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;Ljava/lang/String;)Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;
1049+
public abstract fun imageWireframe (Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;)Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;
10491050
public abstract fun placeholderWireframe (Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;)Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;
10501051
public abstract fun shapeWireframe (Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;)Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;
10511052
public abstract fun textWireframe (Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;)Lcom/datadog/android/internal/sessionreplay/composition/CapturedIdentity;

dd-sdk-android-internal/src/main/java/com/datadog/android/internal/sessionreplay/composition/CapturedIdentity.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,13 @@ data class CapturedIdentity(
109109
* `com.datadog.android.sessionreplay.recorder.composition.CompositionHostDecomposer`) - using the
110110
* same factory instance the native View walker uses, so every identity stays collision-free and
111111
* correctly scoped without a separate id space. Narrowed to only the identity kinds a Compose
112-
* decomposer may legitimately mint - notably no `imageWireframe`/`webViewWireframe`, since it must
113-
* never construct a [CapturedWireframe.Pixel] or [CapturedWireframe.WebView].
114-
* `dd-sdk-android-session-replay`'s internal `CapturedIdentityFactory` extends this with the rest
115-
* (`screenRoot`/`window`/`view`/`layer`/`webViewWireframe`/`imageWireframe`).
112+
* decomposer may legitimately mint - notably no `webViewWireframe`, since it must never construct a
113+
* [CapturedWireframe.WebView] (Compose has no WebView-equivalent embedding surface of its own; a
114+
* real WebView only ever reaches the tree via [CapturedWireframe] produced by the native handoff).
115+
* `imageWireframe` *is* included, for a decomposer that pixel-captures content it can't otherwise
116+
* describe - see `GranularComposeDecomposer`. `dd-sdk-android-session-replay`'s internal
117+
* `CapturedIdentityFactory` extends this with the rest (`screenRoot`/`window`/`view`/`layer`/
118+
* `webViewWireframe`).
116119
*/
117120
interface CompositionIdentityFactory {
118121
/** Mints the identity for a Compose host owned by [window]. */
@@ -129,4 +132,7 @@ interface CompositionIdentityFactory {
129132

130133
/** Mints the identity for a [CapturedWireframe.PrivacyPlaceholder] owned by [owner]. */
131134
fun placeholderWireframe(owner: CapturedIdentity): CapturedIdentity
135+
136+
/** Mints the identity for a [CapturedWireframe.Pixel] owned by [owner]. */
137+
fun imageWireframe(owner: CapturedIdentity): CapturedIdentity
132138
}

features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/granular/GranularComposeDecomposer.kt

Lines changed: 198 additions & 12 deletions
Large diffs are not rendered by default.

features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/granular/GranularComposeDecomposerTest.kt

Lines changed: 220 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,26 @@
66

77
package com.datadog.android.sessionreplay.compose.internal.granular
88

9+
import android.graphics.Bitmap
10+
import android.graphics.Rect
911
import android.view.View
12+
import androidx.compose.ui.Modifier
1013
import androidx.compose.ui.layout.LayoutInfo
14+
import androidx.compose.ui.layout.ModifierInfo
15+
import androidx.compose.ui.node.DrawModifierNode
1116
import androidx.compose.ui.semantics.SemanticsNode
1217
import com.datadog.android.internal.sessionreplay.composition.CapturedIdentity
1318
import com.datadog.android.internal.sessionreplay.composition.CapturedIdentityKind
19+
import com.datadog.android.internal.sessionreplay.composition.CapturedWireframe
1420
import com.datadog.android.internal.sessionreplay.composition.CapturedWireframeKind
1521
import com.datadog.android.internal.sessionreplay.composition.CompositionIdentityFactory
22+
import com.datadog.android.internal.sessionreplay.composition.PixelResource
1623
import com.datadog.android.internal.sessionreplay.composition.RumViewIdentityScope
1724
import com.datadog.android.sessionreplay.compose.internal.utils.ReflectionUtils
1825
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
1926
import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator
27+
import com.datadog.android.sessionreplay.internal.composition.PendingPixelCapture
28+
import com.datadog.android.sessionreplay.internal.composition.PendingPixelCaptureSink
2029
import com.datadog.android.sessionreplay.recorder.composition.CompositionHostDecomposeRequest
2130
import com.datadog.android.sessionreplay.utils.GlobalBounds
2231
import fr.xgouchet.elmyr.annotation.StringForgery
@@ -147,6 +156,7 @@ internal class GranularComposeDecomposerTest {
147156
whenever(mockSemanticsUtils.resolveTextLayoutInfo(eq(node), any())).thenReturn(null)
148157
whenever(mockSemanticsUtils.resolveBackgroundColor(node)).thenReturn(null)
149158
whenever(mockSemanticsUtils.resolveInnerBounds(eq(node), any())).thenReturn(GlobalBounds(0, 0, 10, 10))
159+
whenever(node.boundsInRoot).thenReturn(androidx.compose.ui.geometry.Rect(0f, 0f, 10f, 10f))
150160
return node
151161
}
152162

@@ -156,14 +166,22 @@ internal class GranularComposeDecomposerTest {
156166
return root
157167
}
158168

159-
private fun realRequest(shouldContinue: () -> Boolean): CompositionHostDecomposeRequest {
169+
private fun realRequest(
170+
shouldContinue: () -> Boolean = { true },
171+
pixelCapturePlaceholderLabelFor: (
172+
bounds: com.datadog.android.internal.sessionreplay.composition.CapturedBounds
173+
) -> String? = { null },
174+
pendingPixelCaptureSink: PendingPixelCaptureSink = PendingPixelCaptureSink.NoOp
175+
): CompositionHostDecomposeRequest {
160176
val identityFactory = FakeCompositionIdentityFactory()
161177
return CompositionHostDecomposeRequest(
162178
identityFactory = identityFactory,
163179
hostIdentity = identityFactory.anyIdentity(),
164180
screenDensity = 1f,
165181
nativeViewHandoff = { _, _ -> null },
166-
shouldContinue = shouldContinue
182+
shouldContinue = shouldContinue,
183+
pixelCapturePlaceholderLabelFor = pixelCapturePlaceholderLabelFor,
184+
pendingPixelCaptureSink = pendingPixelCaptureSink
167185
)
168186
}
169187

@@ -222,8 +240,205 @@ internal class GranularComposeDecomposerTest {
222240
// When
223241
testedDecomposer.decompose(mockView, realRequest(shouldContinue = { checkpointCalls++; true }))
224242

243+
// Then - 6 nodes / checkpoint-every-2 = 3, plus 1 more for the pre-batched-draw re-check
244+
// that fires because every childless mockLeafNode() here is itself a pixel-capture
245+
// candidate (see the "pixel capture" test region below).
246+
assertThat(checkpointCalls).isEqualTo(4)
247+
}
248+
249+
// endregion
250+
251+
// region pixel capture
252+
253+
private class FakeComposeHostCaptureRasterizer(
254+
private val bitmapsToReturn: (regions: List<Rect>) -> List<Bitmap?> = { regions -> regions.map { mock() } }
255+
) : ComposeHostCaptureRasterizer {
256+
var callCount = 0
257+
var lastRegions: List<Rect> = emptyList()
258+
259+
override fun captureRegions(hostView: View, regions: List<Rect>): List<Bitmap?> {
260+
callCount++
261+
lastRegions = regions
262+
return bitmapsToReturn(regions)
263+
}
264+
}
265+
266+
/** A node with a real drawing effect - a [DrawModifierNode] in its modifier chain. */
267+
private fun mockDrawingEffectNode(): SemanticsNode {
268+
val node = mockLeafNode()
269+
// A node with its own drawing effect commonly has children too (e.g. a Card wrapping
270+
// content) - non-empty here specifically to prove capture doesn't depend on childlessness.
271+
// Evaluated before any whenever(...).thenReturn(...) below - mockLeafNode() itself performs
272+
// several stubbing calls, and nesting a fresh one inside another's still-open .thenReturn(...)
273+
// argument corrupts Mockito's stubbing recorder (see the block-7 fix for this same mistake
274+
// earlier in this file).
275+
val childNode = mockLeafNode()
276+
val drawModifier = mock<Modifier>(extraInterfaces = arrayOf(DrawModifierNode::class))
277+
val modifierInfo: ModifierInfo = mock()
278+
whenever(modifierInfo.modifier).thenReturn(drawModifier)
279+
whenever(node.layoutInfo.getModifierInfo()).thenReturn(listOf(modifierInfo))
280+
whenever(node.children).thenReturn(listOf(childNode))
281+
return node
282+
}
283+
284+
@Test
285+
fun `M register a pending capture and emit a Pixel wireframe W decompose() { drawing-effect node }`() {
286+
// Given
287+
val node = mockDrawingEffectNode()
288+
val root = mockRoot(listOf(node))
289+
whenever(mockSemanticsUtils.findRootSemanticsNode(mockView)).thenReturn(root)
290+
val fakeRasterizer = FakeComposeHostCaptureRasterizer()
291+
testedDecomposer = GranularComposeDecomposer(
292+
semanticsUtils = mockSemanticsUtils,
293+
reflectionUtils = mockReflectionUtils,
294+
compatibilityGate = testedGate,
295+
hostRasterizer = fakeRasterizer
296+
)
297+
val registered = mutableListOf<PendingPixelCapture>()
298+
299+
// When
300+
val result = testedDecomposer.decompose(
301+
mockView,
302+
realRequest(pendingPixelCaptureSink = PendingPixelCaptureSink { registered += it })
303+
)
304+
305+
// Then
306+
assertThat(result).isNotNull()
307+
assertThat(fakeRasterizer.callCount).isEqualTo(1)
308+
assertThat(registered).hasSize(1)
309+
val pixelWireframe = result!!.wireframes.single() as CapturedWireframe.Pixel
310+
assertThat(pixelWireframe.resource).isEqualTo(PixelResource.Unresolved)
311+
assertThat(pixelWireframe.identity).isEqualTo(registered.single().wireframeIdentity)
312+
// Never recurses into a capture candidate's own children (would double-render).
313+
assertThat(result.wireframes).hasSize(1)
314+
}
315+
316+
@Test
317+
fun `M register a pending capture W decompose() { childless leaf with no text or shape }`() {
318+
// Given - mockLeafNode() is itself exactly this: childless, no text, no resolvable background.
319+
val node = mockLeafNode()
320+
val root = mockRoot(listOf(node))
321+
whenever(mockSemanticsUtils.findRootSemanticsNode(mockView)).thenReturn(root)
322+
val fakeRasterizer = FakeComposeHostCaptureRasterizer()
323+
testedDecomposer = GranularComposeDecomposer(
324+
semanticsUtils = mockSemanticsUtils,
325+
reflectionUtils = mockReflectionUtils,
326+
compatibilityGate = testedGate,
327+
hostRasterizer = fakeRasterizer
328+
)
329+
330+
// When
331+
val result = testedDecomposer.decompose(mockView, realRequest())
332+
333+
// Then
334+
assertThat(result).isNotNull()
335+
assertThat(fakeRasterizer.callCount).isEqualTo(1)
336+
assertThat(result!!.wireframes.single()).isInstanceOf(CapturedWireframe.Pixel::class.java)
337+
}
338+
339+
@Test
340+
fun `M call the rasterizer exactly once W decompose() { two capture candidates under one host }`() {
341+
// Given - the shared-draw batching guarantee: repeated View#draw on the same Compose host
342+
// within one cycle can corrupt a stateful Painter's internal state, so this must never be N calls.
343+
val nodes = listOf(mockLeafNode(), mockLeafNode())
344+
val root = mockRoot(nodes)
345+
whenever(mockSemanticsUtils.findRootSemanticsNode(mockView)).thenReturn(root)
346+
val fakeRasterizer = FakeComposeHostCaptureRasterizer()
347+
testedDecomposer = GranularComposeDecomposer(
348+
semanticsUtils = mockSemanticsUtils,
349+
reflectionUtils = mockReflectionUtils,
350+
compatibilityGate = testedGate,
351+
hostRasterizer = fakeRasterizer
352+
)
353+
354+
// When
355+
val result = testedDecomposer.decompose(mockView, realRequest())
356+
357+
// Then
358+
assertThat(result).isNotNull()
359+
assertThat(fakeRasterizer.callCount).isEqualTo(1)
360+
assertThat(fakeRasterizer.lastRegions).hasSize(2)
361+
assertThat(result!!.wireframes).hasSize(2)
362+
}
363+
364+
@Test
365+
fun `M emit a placeholder without registering a capture W decompose() { privacy denies }`(
366+
@StringForgery fakeLabel: String
367+
) {
368+
// Given
369+
val node = mockLeafNode()
370+
val root = mockRoot(listOf(node))
371+
whenever(mockSemanticsUtils.findRootSemanticsNode(mockView)).thenReturn(root)
372+
val fakeRasterizer = FakeComposeHostCaptureRasterizer()
373+
testedDecomposer = GranularComposeDecomposer(
374+
semanticsUtils = mockSemanticsUtils,
375+
reflectionUtils = mockReflectionUtils,
376+
compatibilityGate = testedGate,
377+
hostRasterizer = fakeRasterizer
378+
)
379+
val registered = mutableListOf<PendingPixelCapture>()
380+
381+
// When
382+
val result = testedDecomposer.decompose(
383+
mockView,
384+
realRequest(
385+
pixelCapturePlaceholderLabelFor = { fakeLabel },
386+
pendingPixelCaptureSink = PendingPixelCaptureSink { registered += it }
387+
)
388+
)
389+
390+
// Then
391+
assertThat(result).isNotNull()
392+
assertThat(fakeRasterizer.callCount).isEqualTo(0)
393+
assertThat(registered).isEmpty()
394+
val placeholder = result!!.wireframes.single() as CapturedWireframe.PrivacyPlaceholder
395+
assertThat(placeholder.label).isEqualTo(fakeLabel)
396+
}
397+
398+
@Test
399+
fun `M emit a placeholder W decompose() { rasterizer returns null for the region }`() {
400+
// Given
401+
val node = mockLeafNode()
402+
val root = mockRoot(listOf(node))
403+
whenever(mockSemanticsUtils.findRootSemanticsNode(mockView)).thenReturn(root)
404+
val fakeRasterizer = FakeComposeHostCaptureRasterizer(bitmapsToReturn = { regions -> regions.map { null } })
405+
testedDecomposer = GranularComposeDecomposer(
406+
semanticsUtils = mockSemanticsUtils,
407+
reflectionUtils = mockReflectionUtils,
408+
compatibilityGate = testedGate,
409+
hostRasterizer = fakeRasterizer
410+
)
411+
412+
// When
413+
val result = testedDecomposer.decompose(mockView, realRequest())
414+
225415
// Then
226-
assertThat(checkpointCalls).isEqualTo(3) // 6 nodes / checkpoint-every-2
416+
assertThat(result).isNotNull()
417+
assertThat(result!!.wireframes.single()).isInstanceOf(CapturedWireframe.PrivacyPlaceholder::class.java)
418+
}
419+
420+
@Test
421+
fun `M abort before the batched draw W decompose() { shouldContinue denies right before capture }`() {
422+
// Given
423+
val node = mockLeafNode()
424+
val root = mockRoot(listOf(node))
425+
whenever(mockSemanticsUtils.findRootSemanticsNode(mockView)).thenReturn(root)
426+
val fakeRasterizer = FakeComposeHostCaptureRasterizer()
427+
testedDecomposer = GranularComposeDecomposer(
428+
semanticsUtils = mockSemanticsUtils,
429+
reflectionUtils = mockReflectionUtils,
430+
compatibilityGate = testedGate,
431+
hostRasterizer = fakeRasterizer,
432+
nodesPerCheckpoint = 1000 // never trips mid-walk; only the pre-capture check should fire
433+
)
434+
435+
// When - denies every call, so the walk's own single node still passes (checkpoint never
436+
// reached with only 1 node) but the dedicated pre-capture re-check catches it.
437+
val result = testedDecomposer.decompose(mockView, realRequest(shouldContinue = { false }))
438+
439+
// Then
440+
assertThat(result).isNull()
441+
assertThat(fakeRasterizer.callCount).isEqualTo(0)
227442
}
228443

229444
// endregion
@@ -252,5 +467,7 @@ internal class GranularComposeDecomposerTest {
252467
next(CapturedIdentityKind.WIREFRAME, CapturedWireframeKind.TEXT)
253468
override fun placeholderWireframe(owner: CapturedIdentity) =
254469
next(CapturedIdentityKind.WIREFRAME, CapturedWireframeKind.PLACEHOLDER)
470+
override fun imageWireframe(owner: CapturedIdentity) =
471+
next(CapturedIdentityKind.WIREFRAME, CapturedWireframeKind.IMAGE)
255472
}
256473
}

features/dd-sdk-android-session-replay/api/apiSurface

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ class com.datadog.android.sessionreplay._SessionReplayInternalProxy
8989
class com.datadog.android.sessionreplay.internal.TouchPrivacyManager
9090
constructor(com.datadog.android.sessionreplay.TouchPrivacy)
9191
fun addTouchOverrideArea(android.graphics.Rect, com.datadog.android.sessionreplay.TouchPrivacy)
92+
data class com.datadog.android.sessionreplay.internal.composition.PendingPixelCapture
93+
constructor(com.datadog.android.internal.sessionreplay.composition.CapturedIdentity, com.datadog.android.internal.sessionreplay.composition.CapturedIdentity, android.graphics.Bitmap, Boolean = false)
94+
interface com.datadog.android.sessionreplay.internal.composition.PendingPixelCaptureSink
95+
fun register(PendingPixelCapture)
96+
companion object
97+
val NoOp: PendingPixelCaptureSink
9298
interface com.datadog.android.sessionreplay.internal.recorder.obfuscator.StringObfuscator
9399
fun obfuscate(String): String
94100
companion object
@@ -105,7 +111,7 @@ interface com.datadog.android.sessionreplay.recorder.composition.CompositionHost
105111
fun canDecompose(android.view.View): Boolean
106112
fun decompose(android.view.View, CompositionHostDecomposeRequest): CompositionHostDecomposeResult?
107113
class com.datadog.android.sessionreplay.recorder.composition.CompositionHostDecomposeRequest
108-
constructor(com.datadog.android.internal.sessionreplay.composition.CompositionIdentityFactory, com.datadog.android.internal.sessionreplay.composition.CapturedIdentity, Float, (android.view.View, com.datadog.android.internal.sessionreplay.composition.CapturedIdentity) -> CompositionNativeSubtree?, () -> Boolean = { true })
114+
constructor(com.datadog.android.internal.sessionreplay.composition.CompositionIdentityFactory, com.datadog.android.internal.sessionreplay.composition.CapturedIdentity, Float, (android.view.View, com.datadog.android.internal.sessionreplay.composition.CapturedIdentity) -> CompositionNativeSubtree?, () -> Boolean = { true }, (com.datadog.android.internal.sessionreplay.composition.CapturedBounds) -> String? = { null }, com.datadog.android.sessionreplay.internal.composition.PendingPixelCaptureSink = PendingPixelCaptureSink.NoOp)
109115
class com.datadog.android.sessionreplay.recorder.composition.CompositionNativeSubtree
110116
constructor(com.datadog.android.internal.sessionreplay.composition.CapturedLayer, List<com.datadog.android.internal.sessionreplay.composition.CapturedLayer>, List<com.datadog.android.internal.sessionreplay.composition.CapturedWireframe>)
111117
class com.datadog.android.sessionreplay.recorder.composition.CompositionHostDecomposeResult

0 commit comments

Comments
 (0)