diff --git a/CHANGELOG.md b/CHANGELOG.md index 54dff9fef7f..1e90e1a1451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) ### Features diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 60d38c0ae0a..97a80aac9e2 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id("com.android.library") @@ -91,6 +92,80 @@ dependencies { testImplementation(libs.coil.compose) } +// 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().configureEach { + if (name == "compileReleaseKotlin" || name == "compileDebugKotlin") { + dependsOn(compileCompose110) + libraries.from(compose110Output) + } +} + +// Include compose110 classes in the AAR +android.libraryVariants.all { + registerPreJavacGeneratedBytecode(project.files(compileCompose110.map { it.outputs.files })) +} + tasks.withType().configureEach { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() diff --git a/sentry-android-replay/src/compose110/kotlin/io/sentry/android/replay/viewhierarchy/Compose110Helper.kt b/sentry-android-replay/src/compose110/kotlin/io/sentry/android/replay/viewhierarchy/Compose110Helper.kt new file mode 100644 index 00000000000..f25e8d6acd5 --- /dev/null +++ b/sentry-android-replay/src/compose110/kotlin/io/sentry/android/replay/viewhierarchy/Compose110Helper.kt @@ -0,0 +1,23 @@ +@file:Suppress( + "INVISIBLE_MEMBER", + "INVISIBLE_REFERENCE", + "EXPOSED_PARAMETER_TYPE", + "EXPOSED_RETURN_TYPE", + "EXPOSED_FUNCTION_RETURN_TYPE", +) + +package io.sentry.android.replay.viewhierarchy + +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 = node.children + + public fun getOuterCoordinator(node: LayoutNode): NodeCoordinator = node.outerCoordinator +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index 2e58418c3ac..cdb1be8efa1 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -157,7 +157,7 @@ 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, @@ -165,7 +165,7 @@ internal object ComposeViewHierarchyNode { } val isVisible = - !node.outerCoordinator.isTransparent() && + !SentryLayoutNodeHelper.isTransparent(node) && (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && visibleRect.height() > 0 && visibleRect.width() > 0 @@ -301,7 +301,7 @@ internal object ComposeViewHierarchyNode { options: SentryMaskingOptions, logger: ILogger, ) { - val children = this.children + val children = SentryLayoutNodeHelper.getChildren(this) if (children.isEmpty()) { return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt new file mode 100644 index 00000000000..7a04b615fde --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt @@ -0,0 +1,59 @@ +@file:Suppress( + "INVISIBLE_MEMBER", + "INVISIBLE_REFERENCE", + "EXPOSED_PARAMETER_TYPE", + "EXPOSED_RETURN_TYPE", + "EXPOSED_FUNCTION_RETURN_TYPE", +) + +package io.sentry.android.replay.viewhierarchy + +import androidx.compose.ui.node.LayoutNode + +/** + * 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. + */ +internal 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 + } + + fun getChildren(node: LayoutNode): List { + return if (useCompose110 == false) { + node.children + } else { + try { + getHelper().getChildren(node).also { useCompose110 = true } + } catch (_: NoSuchMethodError) { + useCompose110 = false + node.children + } + } + } + + 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() + } + } + } +} diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt index bf6a55110be..97a4ec6d491 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt @@ -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 + 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) {