Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Fixes

- Android: Remove the dependency on protobuf-lite for tombstones ([#5157](https://github.com/getsentry/sentry-java/pull/5157))
- Support masking/unmasking and click/scroll detection for Jetpack Compose 1.10+ ([#5189](https://github.com/getsentry/sentry-java/pull/5189))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 🚫 The changelog entry seems to be part of an already released section ## 8.35.0.
    Consider moving the entry to the ## Unreleased section, please.


### Features

Expand Down
1 change: 1 addition & 0 deletions sentry-android-replay/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ kotlin { explicitApi() }

dependencies {
api(projects.sentry)
api(projects.sentryCompose)

compileOnly(libs.androidx.compose.ui.replay)
implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import io.sentry.android.replay.util.toOpaque
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
import io.sentry.compose.SentryLayoutNodeHelper
import java.lang.ref.WeakReference
import java.lang.reflect.Method

Expand Down Expand Up @@ -157,15 +158,15 @@ internal object ComposeViewHierarchyNode {
shouldMask = true,
isImportantForContentCapture = false, // will be set by children
isVisible =
!node.outerCoordinator.isTransparent() &&
!SentryLayoutNodeHelper.isTransparent(node) &&
visibleRect.height() > 0 &&
visibleRect.width() > 0,
visibleRect = visibleRect,
)
}

val isVisible =
!node.outerCoordinator.isTransparent() &&
!SentryLayoutNodeHelper.isTransparent(node) &&
(semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) &&
visibleRect.height() > 0 &&
visibleRect.width() > 0
Expand Down Expand Up @@ -301,7 +302,7 @@ internal object ComposeViewHierarchyNode {
options: SentryMaskingOptions,
logger: ILogger,
) {
val children = this.children
val children = SentryLayoutNodeHelper.getChildren(this)
if (children.isEmpty()) {
return
}
Expand Down
7 changes: 7 additions & 0 deletions sentry-compose/api/android/sentry-compose.api
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ public final class io/sentry/compose/SentryComposeTracingKt {
public static final fun SentryTraced (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
}

public final class io/sentry/compose/SentryLayoutNodeHelper {
public static final field $stable I
public static final field INSTANCE Lio/sentry/compose/SentryLayoutNodeHelper;
public final fun getChildren (Landroidx/compose/ui/node/LayoutNode;)Ljava/util/List;
public final fun isTransparent (Landroidx/compose/ui/node/LayoutNode;)Z
}

public final class io/sentry/compose/SentryModifier {
public static final field $stable I
public static final field INSTANCE Lio/sentry/compose/SentryModifier;
Expand Down
75 changes: 75 additions & 0 deletions sentry-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import io.gitlab.arturbosch.detekt.Detekt
import org.jetbrains.dokka.gradle.DokkaTask
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
alias(libs.plugins.kotlin.multiplatform)
Expand Down Expand Up @@ -118,6 +119,80 @@ android {
}
}

// Compile Compose110Helper.kt against Compose 1.10 where internal LayoutNode accessors
// are mangled with module name "ui" (e.g. getChildren$ui()) instead of "ui_release"
val compose110Classpath by
configurations.creating {
isCanBeConsumed = false
isCanBeResolved = true
attributes {
attribute(Attribute.of("artifactType", String::class.java), "android-classes-jar")
}
}

val compose110KotlinCompiler by
configurations.creating {
isCanBeConsumed = false
isCanBeResolved = true
}

dependencies {
//noinspection UseTomlInstead
compose110Classpath("androidx.compose.ui:ui-android:1.10.0")
//noinspection UseTomlInstead
compose110KotlinCompiler("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.0")
}

val compileCompose110 by
tasks.registering(JavaExec::class) {
val sourceDir = file("src/compose110/kotlin")
val outputDir = layout.buildDirectory.dir("classes/kotlin/compose110")
val compileClasspathFiles = compose110Classpath.incoming.files

inputs.dir(sourceDir)
inputs.files(compileClasspathFiles)
outputs.dir(outputDir)

classpath = compose110KotlinCompiler
mainClass.set("org.jetbrains.kotlin.cli.jvm.K2JVMCompiler")

argumentProviders.add(
CommandLineArgumentProvider {
val cp = compileClasspathFiles.files.joinToString(File.pathSeparator)
outputDir.get().asFile.mkdirs()
listOf(
sourceDir.absolutePath,
"-classpath",
cp,
"-d",
outputDir.get().asFile.absolutePath,
"-jvm-target",
"1.8",
"-language-version",
"1.9",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-Xsuppress-version-warnings",
"-no-stdlib",
)
}
)
}

// Make compose110 output available to the Android Kotlin compilation
val compose110Output = files(compileCompose110.map { it.outputs.files })

tasks.withType<KotlinCompile>().configureEach {
if (name == "compileReleaseKotlinAndroid" || name == "compileDebugKotlinAndroid") {
dependsOn(compileCompose110)
libraries.from(compose110Output)
}
}

// Include compose110 classes in the AAR
android.libraryVariants.all {
registerPreJavacGeneratedBytecode(project.files(compileCompose110.map { it.outputs.files }))
}

tasks.withType<Detekt>().configureEach {
// Target version of the generated JVM bytecode. It is used for type resolution.
jvmTarget = JavaVersion.VERSION_1_8.toString()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@file:Suppress(
"INVISIBLE_MEMBER",
"INVISIBLE_REFERENCE",
"EXPOSED_PARAMETER_TYPE",
"EXPOSED_RETURN_TYPE",
"EXPOSED_FUNCTION_RETURN_TYPE",
)

package io.sentry.compose

import androidx.compose.ui.node.LayoutNode
import org.jetbrains.annotations.ApiStatus

/**
* Provides access to internal LayoutNode members that are subject to Kotlin name-mangling.
*
* LayoutNode.children and LayoutNode.outerCoordinator are Kotlin `internal`, so their getters are
* mangled with the module name: getChildren$ui_release() in Compose < 1.10 vs getChildren$ui() in
* Compose >= 1.10. This class detects the version on first use and delegates to the correct
* accessor.
*/
@ApiStatus.Internal
public object SentryLayoutNodeHelper {
@Volatile private var compose110Helper: Compose110Helper? = null
@Volatile private var useCompose110: Boolean? = null

private fun getHelper(): Compose110Helper {
compose110Helper?.let {
return it
}
val helper = Compose110Helper()
compose110Helper = helper
return helper
}

public fun getChildren(node: LayoutNode): List<LayoutNode> {
return if (useCompose110 == false) {
node.children
} else {
try {
getHelper().getChildren(node).also { useCompose110 = true }
} catch (_: NoSuchMethodError) {
useCompose110 = false
node.children
}
}
}

public fun isTransparent(node: LayoutNode): Boolean {
return if (useCompose110 == false) {
node.outerCoordinator.isTransparent()
} else {
try {
getHelper().getOuterCoordinator(node).isTransparent().also { useCompose110 = true }
} catch (_: NoSuchMethodError) {
useCompose110 = false
node.outerCoordinator.isTransparent()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,12 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT

// the last known tag when iterating the node tree
var lastKnownTag: String? = null
var isClickable = false
var isScrollable = false
Comment on lines +55 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The isClickable and isScrollable flags are not reset within the node processing loop, causing their state to leak across different UI nodes and leading to incorrect target identification.
Severity: MEDIUM

Suggested Fix

Move the declaration and initialization of isClickable and isScrollable inside the while loop. This will ensure they are reset for each UI node being processed, preventing state from persisting across loop iterations.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt#L55-L56

Potential issue: In `ComposeGestureTargetLocator`, the boolean flags `isClickable` and
`isScrollable` are declared outside the `while` loop that iterates through UI nodes.
These flags are set to `true` when a node with a corresponding modifier is found, but
they are never reset to `false` for subsequent nodes in the same traversal. This causes
the state to persist across iterations. As a result, if a parent node is clickable or
scrollable, its child nodes processed later in the queue will incorrectly inherit this
status, leading to incorrect gesture target identification for session replays.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Click/scroll flags never reset between sibling nodes

Medium Severity

Moving isClickable and isScrollable outside the while loop means they are never reset between nodes. Previously they were reset to false for each in-bounds node. Now, once any node sets isClickable = true, every subsequent in-bounds node with a tag will overwrite targetTag via targetTag = lastKnownTag, even if that node isn't clickable itself. This can cause the wrong element to be reported as the gesture target — e.g., a non-clickable sibling or child that merely has a tag could replace the correct clickable target.

Additional Locations (1)
Fix in Cursor Fix in Web


while (!queue.isEmpty()) {
val node = queue.poll() ?: continue
if (node.isPlaced && layoutNodeBoundsContain(rootLayoutNode, node, x, y)) {
var isClickable = false
var isScrollable = false

val modifiers = node.getModifierInfo()
for (index in modifiers.indices) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
@file:Suppress(
"INVISIBLE_MEMBER",
"INVISIBLE_REFERENCE",
"EXPOSED_PARAMETER_TYPE",
"EXPOSED_RETURN_TYPE",
"EXPOSED_FUNCTION_RETURN_TYPE",
)

package io.sentry.compose

import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.node.NodeCoordinator

/**
* Compiled against Compose >= 1.10 where internal LayoutNode accessors are mangled with the module
* name "ui" (e.g. getChildren$ui(), getOuterCoordinator$ui()) instead of "ui_release" used in
* earlier versions.
*/
public class Compose110Helper {
public fun getChildren(node: LayoutNode): List<LayoutNode> = node.children

public fun getOuterCoordinator(node: LayoutNode): NodeCoordinator = node.outerCoordinator
}
Loading