From 9b798c8f4885110607bbceacd336be40c3f62a83 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 4 Aug 2026 16:29:46 +0200 Subject: [PATCH 1/3] fix(compose): Restore Compose 1.6 compatibility for sentry-compose (JAVA-681) sentry-compose is compiled against androidx.compose.material3, which drags its own transitive compose-runtime and foundation versions onto the compile classpath. Since material3 was bumped to 1.4.0 in #5017, that raised those to runtime 1.9.0 and foundation-layout 1.8.1, well above the androidxCompose = 1.6.3 floor the module claims to support. Compose inlines composables such as Box into calling bytecode, so the published SentryComposeTracingKt contained no BoxKt.Box call at all and instead referenced Composer.shouldExecute (runtime 1.8.0+), BoxKt.maybeCachedBoxMeasurePolicy (foundation-layout 1.7.0+) and ComposableLambdaKt.rememberComposableLambda. On an app resolving an older Compose those symbols do not exist, so composing SentryTraced threw NoSuchMethodError. SentryUserFeedbackButton was affected the same way. Pin the compileOnly material3 used by sentry-compose to the oldest version we support instead, so the inlined internals stay resolvable on the declared floor. The unpinned alias is left untouched for the samples and test modules that need newer material3 APIs. Fixes SDK-CRASHES-JAVA-48AR Co-Authored-By: Claude Opus 5 (1M context) --- gradle/libs.versions.toml | 14 ++++++++++++++ sentry-compose/build.gradle.kts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 498e4c0e3e..735c3c0ee6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,10 @@ androidxLifecycle = "2.2.0" androidxNavigation = "2.4.2" androidxTestCore = "1.7.0" androidxCompose = "1.6.3" +# Oldest material3 we support. Kept in lockstep with androidxCompose: material3 drags its own +# transitive Compose versions onto the compile classpath, so a newer value here raises the effective +# Compose floor of sentry-compose's published bytecode. +androidxComposeMaterial3Floor = "1.2.1" asyncProfiler = "4.4" camerax = "1.4.0" composeCompiler = "1.5.14" @@ -89,6 +93,12 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidxCompose" } androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout", version.ref = "androidxCompose" } androidx-compose-material3 = { module = "androidx.compose.material3:material3", version = "1.4.0" } +# Compiling against material3 pulls its transitive compose-runtime/foundation onto the classpath, and +# inline composables like Box bake those internals straight into our bytecode. Keep this at the oldest +# material3 we support so sentry-compose stays loadable on androidxCompose above. +# Note: don't change without testing backwards compatibility. +# :sentry-android-integration-tests:compose-floor-integration-tests guards this. +androidx-compose-material3-floor = { module = "androidx.compose.material3:material3", version.ref = "androidxComposeMaterial3Floor" } androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core", version="1.7.8" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version="1.7.8" } androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidxCompose" } @@ -240,6 +250,10 @@ tomcat-embed-jasper-jakarta = { module = "org.apache.tomcat.embed:tomcat-embed-j # test libraries androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version = "1.4.1" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version = "1.9.5" } +# Floor-matched test harness for :sentry-android-integration-tests:compose-floor-integration-tests. +# Must track androidxCompose, otherwise the test runtime pulls a newer Compose and the check silently +# passes. +androidx-compose-ui-test-junit4-floor = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidxCompose" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" } androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidxTestCore" } androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 8b835ba16f..e11f8dc4de 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -55,7 +55,7 @@ kotlin { api(projects.sentry) api(projects.sentryAndroidNavigation) - compileOnly(libs.androidx.compose.material3) + compileOnly(libs.androidx.compose.material3.floor) compileOnly(libs.androidx.navigation.compose) implementation(libs.androidx.lifecycle.common.java8) } From 6f2c01e747900c15473589f42a2af6312e3d71b2 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 4 Aug 2026 16:30:05 +0200 Subject: [PATCH 2/3] test(compose): Guard the supported Compose floor for sentry-compose (JAVA-681) Nothing asserted which Compose version sentry-compose stays loadable on, so the NoSuchMethodError fixed in the previous commit shipped unnoticed for 19 releases while the existing Compose tests kept passing. Those tests run on a recent Compose, where the inlined internals always resolve, so they cannot catch a floor mismatch by construction. Add a module that consumes sentry-compose with Compose forced to the declared floor and composes its public entry points. It needs to be separate because sentry-compose's own androidUnitTest pins ui-test-junit4 to 1.9.5, which would pull a newer Compose onto the test runtime and make the check pass regardless. The test runs under Robolectric, so it needs no emulator. Verified it fails with the exact production error when the material3 pin is reverted: java.lang.NoSuchMethodError: 'boolean androidx.compose.runtime.Composer.shouldExecute(boolean, int)' The module lives under sentry-android-integration-tests so the root build excludes it from publishing; a name containing "-compose" outside that path is treated as a publishable multiplatform artifact. Co-Authored-By: Claude Opus 5 (1M context) --- .../build.gradle.kts | 84 +++++++++++++++++++ .../ComposeFloorCompatibilityTest.kt | 66 +++++++++++++++ settings.gradle.kts | 1 + 3 files changed, 151 insertions(+) create mode 100644 sentry-android-integration-tests/compose-floor-integration-tests/build.gradle.kts create mode 100644 sentry-android-integration-tests/compose-floor-integration-tests/src/test/kotlin/io/sentry/compose/floortest/ComposeFloorCompatibilityTest.kt diff --git a/sentry-android-integration-tests/compose-floor-integration-tests/build.gradle.kts b/sentry-android-integration-tests/compose-floor-integration-tests/build.gradle.kts new file mode 100644 index 0000000000..0050e5e24d --- /dev/null +++ b/sentry-android-integration-tests/compose-floor-integration-tests/build.gradle.kts @@ -0,0 +1,84 @@ +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +plugins { + id("com.android.library") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.detekt) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "io.sentry.compose.floortest" + + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + + kotlin { + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 + } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + checkReleaseBuilds = false + } + + androidComponents.beforeVariants { + it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + } +} + +// This module exists purely to run sentry-compose against the oldest Compose we claim to support. +// Compose artifacts are inlined into consumer bytecode, so building sentry-compose against a newer +// Compose can emit references to internals that do not exist on the floor, which only surfaces as a +// NoSuchMethodError at runtime on a consumer's older Compose. Pinning here makes that a test +// failure. +// The pins must stay at the floor: raising them silently disables the check this module provides. +configurations.configureEach { + resolutionStrategy { + val floor = libs.versions.androidxCompose.get() + eachDependency { + when (requested.group) { + "androidx.compose.material3" -> + useVersion(libs.versions.androidxComposeMaterial3Floor.get()) + "androidx.compose.runtime", + "androidx.compose.foundation", + "androidx.compose.ui", + "androidx.compose.animation" -> useVersion(floor) + } + } + } +} + +dependencies { + implementation(projects.sentryCompose) + + testImplementation(libs.androidx.compose.material3.floor) + testImplementation(libs.androidx.compose.ui.test.junit4.floor) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.roboelectric) +} + +tasks.withType().configureEach { jvmTarget = JavaVersion.VERSION_1_8.toString() } diff --git a/sentry-android-integration-tests/compose-floor-integration-tests/src/test/kotlin/io/sentry/compose/floortest/ComposeFloorCompatibilityTest.kt b/sentry-android-integration-tests/compose-floor-integration-tests/src/test/kotlin/io/sentry/compose/floortest/ComposeFloorCompatibilityTest.kt new file mode 100644 index 0000000000..aaf6fdb805 --- /dev/null +++ b/sentry-android-integration-tests/compose-floor-integration-tests/src/test/kotlin/io/sentry/compose/floortest/ComposeFloorCompatibilityTest.kt @@ -0,0 +1,66 @@ +package io.sentry.compose.floortest + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.material3.Text +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.compose.SentryTraced +import io.sentry.compose.SentryUserFeedbackButton +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** + * Composes the public entry points of sentry-compose against the oldest Compose version we support. + * + * Compose inlines composables such as `Box` into calling bytecode, so sentry-compose can end up + * referencing Compose internals from whatever version it was compiled against. Those references + * resolve fine in our own test suite, which runs on a recent Compose, but throw NoSuchMethodError + * on a consumer using an older one. Compose is pinned to the floor for this module so that mismatch + * fails here instead of in a user's app. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class ComposeFloorCompatibilityTest { + // workaround for robolectric tests with composeRule + // from https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 + @get:Rule(order = 1) + val addActivityToRobolectricRule = + object : TestWatcher() { + override fun starting(description: Description?) { + super.starting(description) + val appContext: Application = ApplicationProvider.getApplicationContext() + Shadows.shadowOf(appContext.packageManager) + .addActivityIfNotPresent( + ComponentName(appContext.packageName, ComponentActivity::class.java.name) + ) + } + } + + @get:Rule(order = 2) val rule = createAndroidComposeRule() + + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun `SentryTraced composes on the oldest supported Compose`() { + rule.setContent { SentryTraced("floor-tag") { Text("content") } } + + rule.onNodeWithText("content").assertExists() + } + + @Suppress("DEPRECATION") + @Test + fun `SentryUserFeedbackButton composes on the oldest supported Compose`() { + rule.setContent { SentryUserFeedbackButton(text = "Report a Bug") } + + rule.onNodeWithText("Report a Bug").assertExists() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 82fad42aee..cffd144fca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -125,6 +125,7 @@ include( "sentry-samples:sentry-samples-spring-boot-4-otlp", "sentry-samples:sentry-samples-spring-boot-4-webflux", "sentry-samples:sentry-samples-netflix-dgs", + "sentry-android-integration-tests:compose-floor-integration-tests", "sentry-android-integration-tests:sentry-uitest-android-critical", "sentry-android-integration-tests:sentry-uitest-android-benchmark", "sentry-android-integration-tests:sentry-uitest-android-macrobenchmark", From 0404fa6bcc1583855624fc12287f4bdf980eff42 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 4 Aug 2026 16:31:20 +0200 Subject: [PATCH 3/3] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ced1fee97..401ac31418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - `ClientReportRecorder` now reads the item count from the envelope item header instead of deserializing the payload, which under sustained rate limiting could pin CPU cores while repeatedly throwing exceptions - Report tasks handed to a no-op `ISentryExecutorService` as cancelled ([#5874](https://github.com/getsentry/sentry-java/pull/5874)) - `NoOpSentryExecutorService` previously returned a `Future` that was never run and never cancelled, so callers could not tell a dropped task from a queued one and `get()` would block until its timeout +- Fix `NoSuchMethodError` when using `SentryTraced` or `SentryUserFeedbackButton` with Jetpack Compose older than 1.8 ([#5887](https://github.com/getsentry/sentry-java/pull/5887)) + - Since `8.32.0`, `sentry-compose` was compiled against a newer `androidx.compose.material3`, whose transitive Compose versions were inlined into the SDK by composables like `Box`. This made the SDK reference Compose internals (`Composer.shouldExecute`, `BoxKt.maybeCachedBoxMeasurePolicy`) that do not exist on older Compose versions. ### Performance