Skip to content

Commit 99fcf17

Browse files
authored
[andr][replay] Add support for ignoring Compose elements via Modifiers (#129)
* [andr][replay] Add support for ignoring Compose elements via Semantic Modifiers * perk up CaptureModifier.kt * move * Add tests and kdocs
1 parent 2d7cf3a commit 99fcf17

File tree

4 files changed

+93
-4
lines changed

4 files changed

+93
-4
lines changed

platform/jvm/gradle-test-app/src/androidTest/java/io/bitdrift/gradletestapp/ComposeReplayTest.kt

+43
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import com.google.common.truth.Truth.assertThat
4545
import io.bitdrift.capture.replay.ReplayCaptureMetrics
4646
import io.bitdrift.capture.replay.ReplayPreviewClient
4747
import io.bitdrift.capture.replay.ReplayType
48+
import io.bitdrift.capture.replay.compose.CaptureModifier.captureIgnore
4849
import io.bitdrift.capture.replay.internal.FilteredCapture
4950
import io.bitdrift.capture.replay.internal.ReplayRect
5051
import org.junit.Before
@@ -315,7 +316,49 @@ class ComposeReplayTest {
315316

316317
val capture = verifyReplayScreen(viewCount = 13)
317318
assertThat(capture).contains(ReplayRect(ReplayType.Button, 0, 109, 351, 126))
319+
assertThat(capture).contains(ReplayRect(ReplayType.Label, 28, 144, 295, 57))
318320
assertThat(capture).contains(ReplayRect(ReplayType.Button, 0, 277, 224, 126))
321+
assertThat(capture).contains(ReplayRect(ReplayType.Label, 45, 312, 135, 57))
322+
}
323+
324+
@Test
325+
fun textButtonIgnoreOneButtonOnly() {
326+
composeRule.setContentWithExplicitRoot {
327+
Column(Modifier.wrapContentWidth()) {
328+
TextButton(onClick = {}) {
329+
Text("Button Text")
330+
}
331+
TextButton(onClick = {}, modifier = Modifier.captureIgnore(ignoreSubTree = false)) {
332+
Text("short")
333+
}
334+
}
335+
}
336+
337+
val capture = verifyReplayScreen(viewCount = 13)
338+
assertThat(capture).contains(ReplayRect(ReplayType.Button, 0, 109, 351, 126))
339+
assertThat(capture).contains(ReplayRect(ReplayType.Label, 28, 144, 295, 57))
340+
assertThat(capture).doesNotContain(ReplayRect(ReplayType.Button, 0, 277, 224, 126))
341+
assertThat(capture).contains(ReplayRect(ReplayType.Label, 45, 312, 135, 57))
342+
}
343+
344+
@Test
345+
fun textButtonIgnoreOneFullTextButton() {
346+
composeRule.setContentWithExplicitRoot {
347+
Column(Modifier.wrapContentWidth()) {
348+
TextButton(onClick = {}) {
349+
Text("Button Text")
350+
}
351+
TextButton(onClick = {}, modifier = Modifier.captureIgnore(ignoreSubTree = true)) {
352+
Text("short")
353+
}
354+
}
355+
}
356+
357+
val capture = verifyReplayScreen(viewCount = 13)
358+
assertThat(capture).contains(ReplayRect(ReplayType.Button, 0, 109, 351, 126))
359+
assertThat(capture).contains(ReplayRect(ReplayType.Label, 28, 144, 295, 57))
360+
assertThat(capture).doesNotContain(ReplayRect(ReplayType.Button, 0, 277, 224, 126))
361+
assertThat(capture).doesNotContain(ReplayRect(ReplayType.Label, 45, 312, 135, 57))
319362
}
320363

321364
@Test

platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ComposeScreen.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import androidx.compose.ui.text.font.FontWeight
2525
import androidx.compose.ui.unit.Dp
2626
import androidx.compose.ui.viewinterop.AndroidView
2727
import androidx.core.text.HtmlCompat
28-
import io.bitdrift.capture.Capture
28+
import io.bitdrift.capture.replay.compose.CaptureModifier.captureIgnore
2929
import timber.log.Timber
3030

3131
@Composable
@@ -62,7 +62,7 @@ fun SecondScreen() {
6262
)
6363
ElevatedButton(
6464
onClick = { Timber.w("Warning logged from Compose Screen") },
65-
modifier = centerWithPaddingModifier.padding(top = normalSpacing)
65+
modifier = centerWithPaddingModifier.padding(top = normalSpacing).captureIgnore(ignoreSubTree = false)
6666
) {
6767
Text("Log-a-log (Warning)")
6868
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// capture-sdk - bitdrift's client SDK
2+
// Copyright Bitdrift, Inc. All rights reserved.
3+
//
4+
// Use of this source code is governed by a source available license that can be found in the
5+
// LICENSE file or at:
6+
// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt
7+
8+
package io.bitdrift.capture.replay.compose
9+
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.semantics.SemanticsPropertyKey
12+
import androidx.compose.ui.semantics.semantics
13+
14+
/**
15+
* Compose Modifiers exposed by the Capture SDK.
16+
*/
17+
object CaptureModifier {
18+
19+
internal val CaptureIgnore = SemanticsPropertyKey<Boolean>(
20+
name = "CaptureIgnoreModifier",
21+
mergePolicy = { parentValue, _ ->
22+
parentValue
23+
},
24+
)
25+
26+
/**
27+
* Semantic Modifier that can be used to ignore elements of the Compose tree from being captured by Session Replay.
28+
*/
29+
@JvmStatic
30+
fun Modifier.captureIgnore(ignoreSubTree: Boolean = false): Modifier {
31+
return semantics(
32+
properties = {
33+
this[CaptureIgnore] = ignoreSubTree
34+
},
35+
)
36+
}
37+
}

platform/jvm/replay/src/main/kotlin/io/bitdrift/capture/replay/internal/compose/ComposeTreeParser.kt

+11-2
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ import androidx.compose.ui.state.ToggleableState
2424
import androidx.compose.ui.unit.toSize
2525
import io.bitdrift.capture.replay.ReplayType
2626
import io.bitdrift.capture.replay.SessionReplayController
27+
import io.bitdrift.capture.replay.compose.CaptureModifier
2728
import io.bitdrift.capture.replay.internal.ReplayRect
2829
import io.bitdrift.capture.replay.internal.ScannableView
29-
import io.bitdrift.capture.replay.internal.compose.ComposeTreeParser.unclippedGlobalBounds
3030

3131
internal object ComposeTreeParser {
3232
internal val View.mightBeComposeView: Boolean
@@ -50,9 +50,18 @@ internal object ComposeTreeParser {
5050
@OptIn(ExperimentalComposeUiApi::class, InternalComposeUiApi::class)
5151
private fun SemanticsNode.toScannableView(): ScannableView {
5252
val notAttachedOrPlaced = !this.layoutNode.isPlaced || !this.layoutNode.isAttached
53-
val isVisible = !this.isTransparent && !unmergedConfig.contains(SemanticsProperties.InvisibleToUser)
53+
val captureIgnoreSubTree = this.unmergedConfig.getOrNull(CaptureModifier.CaptureIgnore)
54+
val isVisible = !this.isTransparent && !this.unmergedConfig.contains(SemanticsProperties.InvisibleToUser)
5455
val type = if (notAttachedOrPlaced) {
5556
return ScannableView.IgnoredComposeView
57+
} else if (captureIgnoreSubTree != null) {
58+
if (captureIgnoreSubTree) {
59+
// short-circuit the entire sub-tree
60+
return ScannableView.IgnoredComposeView
61+
} else {
62+
// just ignore this one element
63+
ReplayType.Ignore
64+
}
5665
} else if (!isVisible) {
5766
ReplayType.TransparentView
5867
} else {

0 commit comments

Comments
 (0)