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 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-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/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) } 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",