From b884f89c374e8e06168d6c6fcb13a6db2e76a6ed Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:10:26 +0000 Subject: [PATCH 01/14] fix(instrumentation): Resolve class availability at task time Move SDK runtime-optimization classpath resolution onto a dedicated task output so AGP no longer resolves *RuntimeClasspath while snapshotting instrumentation inputs during configuration. Refs #1399 Co-Authored-By: Roman Zavarnitsyn --- CHANGELOG.md | 2 + .../android/gradle/AndroidComponentsConfig.kt | 61 +++++----- ...entrySdkOptimizationClassVisitorFactory.kt | 36 ++++-- .../ResolveSdkClassAvailabilityTask.kt | 104 ++++++++++++++++++ .../gradle/util/SentryModulesCollector.kt | 12 +- .../ResolveSdkClassAvailabilityTaskTest.kt | 62 +++++++++++ .../gradle/util/SentryModulesCollectorTest.kt | 28 ++--- 7 files changed, 246 insertions(+), 59 deletions(-) create mode 100644 plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt create mode 100644 plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 949e9dd6c..4294ee7a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixes +- Avoid resolving the variant runtime classpath at configuration time for SDK runtime optimizations ([#1401](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1401)) + - Fixes Gradle warnings like `Configuration 'devDebugRuntimeClasspath' was resolved during configuration time` introduced by the build-time class-availability optimization - Detect AGP `optimization.enable` when the variant is wrapped by AGP analytics ([#1382](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1382)) - This fixes `java.lang.NoSuchMethodException: com.android.build.api.component.analytics.AnalyticsEnabledApplicationVariant_Decorated.getOptimizationCreationConfig()` diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt index ad44625a6..6cdd5e601 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt @@ -21,7 +21,6 @@ import io.sentry.android.gradle.SentryTasksProvider.getMappingFileProvider import io.sentry.android.gradle.extensions.SentryPluginExtension import io.sentry.android.gradle.instrumentation.SentrySdkOptimizationClassVisitorFactory import io.sentry.android.gradle.instrumentation.SpanAddingClassVisitorFactory -import io.sentry.android.gradle.instrumentation.resolveClassAvailability import io.sentry.android.gradle.services.SentryModulesService import io.sentry.android.gradle.snapshot.GenerateSnapshotTestsTask import io.sentry.android.gradle.sourcecontext.OutputPaths @@ -35,6 +34,7 @@ import io.sentry.android.gradle.tasks.SentryUploadAppArtifactTask import io.sentry.android.gradle.tasks.SentryUploadProguardMappingsTask import io.sentry.android.gradle.tasks.SentryUploadSnapshotsTask import io.sentry.android.gradle.tasks.configureNativeSymbolsTask +import io.sentry.android.gradle.tasks.dependencies.ResolveSdkClassAvailabilityTask import io.sentry.android.gradle.tasks.dependencies.SentryExternalDependenciesReportTaskV2 import io.sentry.android.gradle.telemetry.SentryTelemetryService import io.sentry.android.gradle.util.AgpVersions @@ -181,9 +181,34 @@ fun ApplicationAndroidComponentsExtension.configure( val runtimeOptimizationsEnabled = extension.runtimeOptimizations.enabled.get() val tracingInstrumentationEnabled = extension.tracingInstrumentation.enabled.get() - // Both visitor factories need the resolved dependency graph. - val modulesService = - if (runtimeOptimizationsEnabled || tracingInstrumentationEnabled) { + + // Runtime optimizations resolve the classpath via a task output so AGP does not force + // config-time resolution when snapshotting instrumentation inputs. + if (runtimeOptimizationsEnabled) { + val availabilityTask = + ResolveSdkClassAvailabilityTask.register( + project = project, + configurationName = "${variant.name}RuntimeClasspath", + taskSuffix = variant.name.capitalized, + ) + if (availabilityTask != null) { + variant.instrumentation.transformClassesWith( + SentrySdkOptimizationClassVisitorFactory::class.java, + InstrumentationScope.ALL, + ) { params -> + params.classAvailabilityFile.setDisallowChanges( + availabilityTask.flatMap { it.outputFile } + ) + } + variant.instrumentation.setAsmFramesComputationMode( + FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS + ) + } + } + + // Tracing still uses the shared modules service filled via afterResolve. + if (tracingInstrumentationEnabled) { + val tracingModulesService = SentryModulesService.register( project, extension.tracingInstrumentation.features, @@ -196,31 +221,11 @@ fun ApplicationAndroidComponentsExtension.configure( // Keep the service alive after configuration so instrumentation can read it. buildEvents.onTaskCompletion(it) } - } else { - null - } - - val modules = - modulesService?.let { - project.collectModules("${variant.name}RuntimeClasspath", variant.name, it) - } - - if (runtimeOptimizationsEnabled) { - variant.instrumentation.transformClassesWith( - SentrySdkOptimizationClassVisitorFactory::class.java, - InstrumentationScope.ALL, - ) { params -> - params.classAvailability.setDisallowChanges( - checkNotNull(modules).map(::resolveClassAvailability).orElse(emptyMap()) - ) - } - variant.instrumentation.setAsmFramesComputationMode( - FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS + project.collectModules( + "${variant.name}RuntimeClasspath", + variant.name, + tracingModulesService, ) - } - - if (tracingInstrumentationEnabled) { - val tracingModulesService = checkNotNull(modulesService) variant.configureInstrumentation( SpanAddingClassVisitorFactory::class.java, diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt index 5f5cb497c..41707017c 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt @@ -5,17 +5,28 @@ import com.android.build.api.instrumentation.ClassContext import com.android.build.api.instrumentation.ClassData import com.android.build.api.instrumentation.InstrumentationParameters import io.sentry.android.gradle.util.SentryModules +import java.util.Properties import org.gradle.api.artifacts.ModuleIdentifier +import org.gradle.api.file.RegularFileProperty import org.gradle.api.internal.artifacts.DefaultModuleIdentifier -import org.gradle.api.provider.MapProperty -import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity import org.objectweb.asm.ClassVisitor abstract class SentrySdkOptimizationClassVisitorFactory : AsmClassVisitorFactory { interface SdkOptimizationParameters : InstrumentationParameters { - @get:Input val classAvailability: MapProperty + /** + * Properties file produced by + * [io.sentry.android.gradle.tasks.dependencies.ResolveSdkClassAvailabilityTask]. Using a file + * input keeps classpath resolution on the producing task and lets AGP invalidate + * instrumentation when dependencies change. + */ + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + val classAvailabilityFile: RegularFileProperty } override fun createClassVisitor( @@ -25,14 +36,12 @@ abstract class SentrySdkOptimizationClassVisitorFactory : return LoadClassClassVisitor( instrumentationContext.apiVersion.get(), nextClassVisitor, - parameters.get().classAvailability.get(), + readClassAvailability(parameters.get().classAvailabilityFile), ) } - // Empty availability means the runtime classpath is unknown. Skip this transformation so - // LoadClass falls back to reflection. override fun isInstrumentable(classData: ClassData): Boolean = - classData.className == LOAD_CLASS_NAME && parameters.get().classAvailability.get().isNotEmpty() + classData.className == LOAD_CLASS_NAME internal companion object { const val LOAD_CLASS_NAME = "io.sentry.util.LoadClass" @@ -72,3 +81,16 @@ internal fun resolveClassAvailability(modules: Set): Map owners.any { it in modules } } + +internal fun readClassAvailability(fileProperty: RegularFileProperty): Map { + val file = fileProperty.orNull?.asFile ?: return emptyMap() + if (!file.isFile || file.length() == 0L) { + return emptyMap() + } + + val properties = Properties() + file.inputStream().use { properties.load(it) } + return properties.entries.associate { (key, value) -> + key.toString() to value.toString().toBooleanStrict() + } +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt new file mode 100644 index 000000000..1671eb3f9 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt @@ -0,0 +1,104 @@ +package io.sentry.android.gradle.tasks.dependencies + +import io.sentry.android.gradle.instrumentation.resolveClassAvailability +import io.sentry.android.gradle.util.artifactsFor +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.UnknownDomainObjectException +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.internal.artifacts.DefaultModuleIdentifier +import org.gradle.api.provider.SetProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.TaskProvider + +/** + * Resolves the variant runtime classpath at execution time and writes the optional SDK class + * availability map used by + * [io.sentry.android.gradle.instrumentation.SentrySdkOptimizationClassVisitorFactory]. + * + * Keeping resolution on a task input avoids resolving `*RuntimeClasspath` during configuration when + * AGP snapshots instrumentation parameters. + */ +@CacheableTask +abstract class ResolveSdkClassAvailabilityTask : DefaultTask() { + + init { + description = "Resolves optional Sentry SDK class availability from the runtime classpath" + } + + /** External module coordinates in `group:module` form. */ + @get:Input abstract val moduleIds: SetProperty + + @get:OutputFile abstract val outputFile: RegularFileProperty + + @TaskAction + fun action() { + val modules = + moduleIds + .get() + .mapNotNull { coordinate -> + val parts = coordinate.split(':', limit = 2) + if (parts.size != 2) { + null + } else { + DefaultModuleIdentifier.newId(parts[0], parts[1]) + } + } + .toSet() + + val availability = resolveClassAvailability(modules) + val output = outputFile.get().asFile + output.parentFile?.mkdirs() + // Write manually so the file stays deterministic for build caching (no Properties timestamp). + output.bufferedWriter().use { writer -> + availability.toSortedMap().forEach { (className, available) -> + writer.append(className).append('=').append(available.toString()).append('\n') + } + } + } + + companion object { + fun register( + project: Project, + configurationName: String, + attributeValueJar: String = "android-classes", + taskSuffix: String, + ): TaskProvider? { + val configuration = + try { + project.configurations.getByName(configurationName) + } catch (e: UnknownDomainObjectException) { + project.logger.warn( + "Unable to find configuration $configurationName for SDK class availability." + ) + return null + } + + return project.tasks.register( + "resolveSentrySdkClassAvailability$taskSuffix", + ResolveSdkClassAvailabilityTask::class.java, + ) { task -> + // Lazy: artifactsFor(...).resolvedArtifacts only resolves when the task executes. + val artifacts = configuration.artifactsFor(attributeValueJar).resolvedArtifacts + task.moduleIds.set( + artifacts.map { list -> + list + .map { artifact -> artifact.id.componentIdentifier } + .filterIsInstance() + .map { id -> "${id.group}:${id.module}" } + .toSet() + } + ) + task.outputFile.set( + project.layout.buildDirectory.file( + "intermediates/sentry/classAvailability/${taskSuffix}.properties" + ) + ) + } + } + } +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt index a0d161c7e..802f48cac 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryModulesCollector.kt @@ -12,7 +12,7 @@ fun Project.collectModules( configurationName: String, variantName: String, sentryModulesService: Provider, -): Provider> { +) { val configProvider = try { configurations.named(configurationName) @@ -20,9 +20,11 @@ fun Project.collectModules( logger.warn { "Unable to find configuration $configurationName for variant $variantName." } sentryModulesService.get().sentryModules = emptyMap() sentryModulesService.get().externalModules = emptyMap() - return provider> { null } + return } + // Populate the shared service after the configuration resolves. Do not read + // resolutionResult during configuration: that forces config-time resolution. configProvider.configure { configuration -> configuration.incoming.afterResolve { val allModules = it.resolutionResult.allComponents.versionMap(logger) @@ -40,12 +42,6 @@ fun Project.collectModules( sentryModulesService.get().externalModules = externalModules } } - - return configProvider.map { configuration -> - configuration.incoming.resolutionResult.allComponents - .mapNotNull { it.moduleVersion?.module } - .toSet() - } } private fun Set.versionMap( diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt new file mode 100644 index 000000000..0820bb7c6 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt @@ -0,0 +1,62 @@ +package io.sentry.android.gradle.tasks.dependencies + +import com.google.common.truth.Truth.assertThat +import io.sentry.android.gradle.instrumentation.readClassAvailability +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ResolveSdkClassAvailabilityTaskTest { + + @get:Rule val tempDir = TemporaryFolder() + + @Test + fun `writes availability for present and absent modules`() { + val project = createProject() + val output = tempDir.newFile("availability.properties") + + val task = + project.tasks.register( + "testResolveSdkClassAvailability", + ResolveSdkClassAvailabilityTask::class.java, + ) { + it.moduleIds.set(setOf("com.jakewharton.timber:timber", "androidx.core:core")) + it.outputFile.set(output) + } + + task.get().action() + + val availability = readClassAvailability(task.get().outputFile) + assertThat(availability["timber.log.Timber"]).isTrue() + assertThat(availability["androidx.core.view.ScrollingView"]).isTrue() + // No compose dependency in the module set. + assertThat(availability["androidx.compose.ui.node.Owner"]).isFalse() + assertThat(output.readText()).contains("timber.log.Timber=true") + } + + @Test + fun `empty module set still writes known-absent entries`() { + val project = createProject() + val output = tempDir.newFile("availability-empty.properties") + + val task = + project.tasks.register( + "testResolveSdkClassAvailabilityEmpty", + ResolveSdkClassAvailabilityTask::class.java, + ) { + it.moduleIds.set(emptySet()) + it.outputFile.set(output) + } + + task.get().action() + + val availability = readClassAvailability(task.get().outputFile) + assertThat(availability).isNotEmpty() + assertThat(availability.values).doesNotContain(true) + } + + private fun createProject(): Project = + ProjectBuilder.builder().withProjectDir(tempDir.newFolder("project")).build() +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt index 2ace513b5..e178cede6 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesCollectorTest.kt @@ -87,14 +87,12 @@ class SentryModulesCollectorTest { @Test fun `configuration cannot be found - logs a warning and does not provide modules`() { val project = fixture.getSut(testProjectDir.root) - val modules = - project.collectModules( - "releaseRuntimeClasspath", - "release", - fixture.sentryModulesServiceProvider, - ) + project.collectModules( + "releaseRuntimeClasspath", + "release", + fixture.sentryModulesServiceProvider, + ) assertTrue { fixture.getSentryModules().isEmpty() } - assertThat(modules.isPresent).isFalse() assertTrue { fixture.logger.capturedMessage == "[sentry] Unable to find configuration releaseRuntimeClasspath for variant release." @@ -179,7 +177,7 @@ class SentryModulesCollectorTest { } @Test - fun `non sentry dependencies are provided via the modules provider`() { + fun `non sentry dependencies are persisted in the external modules service`() { val group = "androidx.sqlite" val name = "sqlite-framework" val moduleIdentifier = DefaultModuleIdentifier.newId(group, name) @@ -192,15 +190,13 @@ class SentryModulesCollectorTest { } val project = fixture.getSut(testProjectDir.root, dependencies = setOf(sqliteDep)) - val modules = - project.collectModules( - fixture.configurationName, - fixture.variantName, - fixture.sentryModulesServiceProvider, - ) + project.collectModules( + fixture.configurationName, + fixture.variantName, + fixture.sentryModulesServiceProvider, + ) - assertTrue { fixture.getExternalModules()[moduleIdentifier]!! == SemVer.parse(version) } - assertThat(modules.get()).containsExactly(moduleIdentifier) + assertThat(fixture.getExternalModules()[moduleIdentifier]).isEqualTo(SemVer.parse(version)) } @Test From 3a70aa5246215bb001258a6a354ef5f2dda139fd Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:10:48 +0000 Subject: [PATCH 02/14] docs(changelog): Point runtime-opt fix at #1402 Co-Authored-By: Roman Zavarnitsyn --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4294ee7a2..d271cf2c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixes -- Avoid resolving the variant runtime classpath at configuration time for SDK runtime optimizations ([#1401](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1401)) +- Avoid resolving the variant runtime classpath at configuration time for SDK runtime optimizations ([#1402](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1402)) - Fixes Gradle warnings like `Configuration 'devDebugRuntimeClasspath' was resolved during configuration time` introduced by the build-time class-availability optimization - Detect AGP `optimization.enable` when the variant is wrapped by AGP analytics ([#1382](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1382)) - This fixes `java.lang.NoSuchMethodException: com.android.build.api.component.analytics.AnalyticsEnabledApplicationVariant_Decorated.getOptimizationCreationConfig()` From dc1ce67c7813f78fc61ebed3cee03fe5003b1da9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:36:27 +0000 Subject: [PATCH 03/14] test(instrumentation): Expect availability task Co-Authored-By: Roman Zavarnitsyn --- .../io/sentry/android/gradle/integration/SentryPluginTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt index 023df2c6a..360139299 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt @@ -565,8 +565,8 @@ class SentryPluginTest : val build = runner.appendArguments(":app:assembleRelease", "--info").build() + assertTrue(":app:resolveSentrySdkClassAvailabilityRelease" in build.output) assertTrue(":app:transformReleaseClassesWithAsm" in build.output) - assertTrue("Detected Sentry modules" in build.output) } @Test From 49161019b5bff16e2c8fd118e5f3f31d16ea56e9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:13:24 +0000 Subject: [PATCH 04/14] test(instrumentation): Guard config-time classpath resolution Assert the #1399 warning stays gone under --warning-mode all, and add a unit mutation oracle that register() leaves runtimeClasspath unresolved. Co-Authored-By: Roman Zavarnitsyn --- .../gradle/integration/SentryPluginTest.kt | 10 ++++++- .../ResolveSdkClassAvailabilityTaskTest.kt | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt index 360139299..d87f2375c 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt @@ -563,10 +563,18 @@ class SentryPluginTest : fun `registers runtime optimizations independently from tracing instrumentation`() { applyTracingInstrumentation(false, appStart = false, logcat = false) - val build = runner.appendArguments(":app:assembleRelease", "--info").build() + // --warning-mode all surfaces Gradle's config-time resolution warning if it regresses. + val build = + runner.appendArguments(":app:assembleRelease", "--info", "--warning-mode", "all").build() assertTrue(":app:resolveSentrySdkClassAvailabilityRelease" in build.output) assertTrue(":app:transformReleaseClassesWithAsm" in build.output) + // Regression guard for #1399 / #1402: runtime opts must not resolve *RuntimeClasspath while + // AGP snapshots instrumentation inputs during configuration. + assertFalse( + "RuntimeClasspath' was resolved during configuration time" in build.output, + build.output, + ) } @Test diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt index 0820bb7c6..24ccd137b 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt @@ -3,6 +3,7 @@ package io.sentry.android.gradle.tasks.dependencies import com.google.common.truth.Truth.assertThat import io.sentry.android.gradle.instrumentation.readClassAvailability import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration import org.gradle.testfixtures.ProjectBuilder import org.junit.Rule import org.junit.Test @@ -57,6 +58,35 @@ class ResolveSdkClassAvailabilityTaskTest { assertThat(availability.values).doesNotContain(true) } + @Test + fun `register leaves the configuration unresolved`() { + val project = createJavaProjectWithRuntimeClasspath() + val configuration = project.configurations.getByName("runtimeClasspath") + assertThat(configuration.state).isEqualTo(Configuration.State.UNRESOLVED) + + val task = + ResolveSdkClassAvailabilityTask.register( + project = project, + configurationName = "runtimeClasspath", + attributeValueJar = "jar", + taskSuffix = "Test", + ) + requireNotNull(task) + // Realize the task object (runs the configure action) without reading moduleIds. + task.get() + + // Mutation oracle: wiring the task must not force config-time resolution. Reintroducing a + // resolutionResult/map read during register would flip this to RESOLVED and fail the guard. + assertThat(configuration.state).isEqualTo(Configuration.State.UNRESOLVED) + } + private fun createProject(): Project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("project")).build() + + private fun createJavaProjectWithRuntimeClasspath(): Project { + val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("java-project")).build() + project.plugins.apply("java") + // No external deps needed: we only assert that register() stays lazy. + return project + } } From 04f8b932a2df37c07598eaa0163ee05edf8a5aa1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:17:26 +0000 Subject: [PATCH 05/14] fix(instrumentation): Use full resolution graph for availability Derive module ids from resolutionResult.allComponents via a lazy Provider so presence matches the pre-#1402 graph path without config-time resolution, including modules without android-classes. --- .../ResolveSdkClassAvailabilityTask.kt | 19 ++++++++----------- .../ResolveSdkClassAvailabilityTaskTest.kt | 6 +++--- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt index 1671eb3f9..08ad2380e 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt @@ -1,11 +1,9 @@ package io.sentry.android.gradle.tasks.dependencies import io.sentry.android.gradle.instrumentation.resolveClassAvailability -import io.sentry.android.gradle.util.artifactsFor import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.UnknownDomainObjectException -import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.file.RegularFileProperty import org.gradle.api.internal.artifacts.DefaultModuleIdentifier import org.gradle.api.provider.SetProperty @@ -30,7 +28,7 @@ abstract class ResolveSdkClassAvailabilityTask : DefaultTask() { description = "Resolves optional Sentry SDK class availability from the runtime classpath" } - /** External module coordinates in `group:module` form. */ + /** Module coordinates in `group:module` form from the resolved dependency graph. */ @get:Input abstract val moduleIds: SetProperty @get:OutputFile abstract val outputFile: RegularFileProperty @@ -65,7 +63,6 @@ abstract class ResolveSdkClassAvailabilityTask : DefaultTask() { fun register( project: Project, configurationName: String, - attributeValueJar: String = "android-classes", taskSuffix: String, ): TaskProvider? { val configuration = @@ -82,14 +79,14 @@ abstract class ResolveSdkClassAvailabilityTask : DefaultTask() { "resolveSentrySdkClassAvailability$taskSuffix", ResolveSdkClassAvailabilityTask::class.java, ) { task -> - // Lazy: artifactsFor(...).resolvedArtifacts only resolves when the task executes. - val artifacts = configuration.artifactsFor(attributeValueJar).resolvedArtifacts + // Lazy Provider: resolutionResult is only read when the task input is realized at + // execution time. Use allComponents (not artifactsFor) so presence matches the old + // graph-based path, including modules that do not publish android-classes artifacts. task.moduleIds.set( - artifacts.map { list -> - list - .map { artifact -> artifact.id.componentIdentifier } - .filterIsInstance() - .map { id -> "${id.group}:${id.module}" } + project.provider { + configuration.incoming.resolutionResult.allComponents + .mapNotNull { component -> component.moduleVersion?.module } + .map { module -> "${module.group}:${module.name}" } .toSet() } ) diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt index 24ccd137b..29e3aa052 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt @@ -68,15 +68,15 @@ class ResolveSdkClassAvailabilityTaskTest { ResolveSdkClassAvailabilityTask.register( project = project, configurationName = "runtimeClasspath", - attributeValueJar = "jar", taskSuffix = "Test", ) requireNotNull(task) // Realize the task object (runs the configure action) without reading moduleIds. task.get() - // Mutation oracle: wiring the task must not force config-time resolution. Reintroducing a - // resolutionResult/map read during register would flip this to RESOLVED and fail the guard. + // Mutation oracle: wiring the task must not force config-time resolution. Evaluating + // resolutionResult during register (instead of inside a lazy Provider) would flip this to + // RESOLVED and fail the guard. assertThat(configuration.state).isEqualTo(Configuration.State.UNRESOLVED) } From da8756fdda900a2e72dcbc136a2f1cc1005fa679 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:51:57 +0000 Subject: [PATCH 06/14] docs(instrumentation): Explain missing classpath fallback Co-Authored-By: Roman Zavarnitsyn --- .../kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt index 6cdd5e601..8d13f18c5 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt @@ -191,6 +191,8 @@ fun ApplicationAndroidComponentsExtension.configure( configurationName = "${variant.name}RuntimeClasspath", taskSuffix = variant.name.capitalized, ) + // A missing runtime classpath means availability is unknown; skip the transform so the SDK + // falls back to reflection. if (availabilityTask != null) { variant.instrumentation.transformClassesWith( SentrySdkOptimizationClassVisitorFactory::class.java, From 2872513a60d87c0d7a6a9e3a43a49f7b5a67c08e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:03:13 +0000 Subject: [PATCH 07/14] docs(instrumentation): Clarify availability file and task wiring Drop manual OutputFile mkdirs, document the properties format, and explain that the task output is the instrumentation input. --- .../io/sentry/android/gradle/AndroidComponentsConfig.kt | 5 +++-- .../tasks/dependencies/ResolveSdkClassAvailabilityTask.kt | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt index 8d13f18c5..cc8aa6497 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt @@ -182,8 +182,9 @@ fun ApplicationAndroidComponentsExtension.configure( val runtimeOptimizationsEnabled = extension.runtimeOptimizations.enabled.get() val tracingInstrumentationEnabled = extension.tracingInstrumentation.enabled.get() - // Runtime optimizations resolve the classpath via a task output so AGP does not force - // config-time resolution when snapshotting instrumentation inputs. + // Runtime optimizations need the dependency graph as an instrumentation @Input. Reading + // resolutionResult while building params would resolve *RuntimeClasspath at configuration + // time, so a task produces the availability file and that output is wired in as the input. if (runtimeOptimizationsEnabled) { val availabilityTask = ResolveSdkClassAvailabilityTask.register( diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt index 08ad2380e..e11ab42f6 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt @@ -50,8 +50,9 @@ abstract class ResolveSdkClassAvailabilityTask : DefaultTask() { val availability = resolveClassAvailability(modules) val output = outputFile.get().asFile - output.parentFile?.mkdirs() - // Write manually so the file stays deterministic for build caching (no Properties timestamp). + // Deterministic one-entry-per-line properties (no java.util.Properties timestamp header): + // androidx.core.view.ScrollingView=true + // timber.log.Timber=false output.bufferedWriter().use { writer -> availability.toSortedMap().forEach { (className, available) -> writer.append(className).append('=').append(available.toString()).append('\n') From b4688846d48ef2a27ac40ee360ba3a5fe91a5c21 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:59:52 +0000 Subject: [PATCH 08/14] test(instrumentation): Cover LoadClass availability injection E2E Add a unit glue test from task file through visitor injection, and an integration test that checks the availability properties plus injected LoadClass bytecode after assemble with a supporting SDK. Co-Authored-By: Roman Zavarnitsyn --- .../io/sentry/android/gradle/TestUtils.kt | 34 ++++++++ .../LoadClassClassVisitorTest.kt | 36 ++++++++ .../gradle/integration/SentryPluginTest.kt | 86 +++++++++++++++++++ 3 files changed, 156 insertions(+) diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt index bfd86587e..9fda84fa0 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt @@ -175,6 +175,40 @@ private fun extractZip(zipFile: File, fileToExtract: String): String { return "" } +internal fun extractZipBytes(zipFile: File, fileToExtract: String): ByteArray? { + val zip = ZipFile(zipFile) + return try { + zip.getInputStream(zip.getFileHeader(fileToExtract)).use { zis -> zis.readBytes() } + } catch (_: ZipException) { + null + } +} + +/** Finds instrumented `io/sentry/util/LoadClass.class` under a build directory. */ +internal fun findLoadClassBytecode(rootFile: File): ByteArray { + val classRelativePath = "io/sentry/util/LoadClass.class" + val classFile = + rootFile.walkTopDown().firstOrNull { file -> + file.isFile && + file.name == "LoadClass.class" && + file.invariantSeparatorsPath.endsWith(classRelativePath) + } + if (classFile != null) { + return classFile.readBytes() + } + + rootFile + .walkTopDown() + .filter { it.isFile && it.extension == "jar" } + .forEach { jar -> + extractZipBytes(jar, classRelativePath)?.let { + return it + } + } + + error("Could not find instrumented $classRelativePath under ${rootFile.absolutePath}") +} + private fun readZippedContent(zipInputStream: ZipInputStream): String { val baos = ByteArrayOutputStream() val content = ByteArray(1024) diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitorTest.kt index 723192d31..5eb854bc9 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitorTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/LoadClassClassVisitorTest.kt @@ -1,14 +1,21 @@ package io.sentry.android.gradle.instrumentation import com.google.common.truth.Truth.assertThat +import io.sentry.android.gradle.tasks.dependencies.ResolveSdkClassAvailabilityTask import io.sentry.android.gradle.util.SentryModules import org.gradle.api.internal.artifacts.DefaultModuleIdentifier +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import org.objectweb.asm.ClassReader import org.objectweb.asm.ClassWriter import org.objectweb.asm.Opcodes class LoadClassClassVisitorTest { + + @get:Rule val tempDir = TemporaryFolder() + @Test fun `resolves every known class from the module graph`() { val modules = SentrySdkOptimizationClassVisitorFactory.CLASS_MODULES.values.flatten().toSet() @@ -70,6 +77,35 @@ class LoadClassClassVisitorTest { assertThat(clazz.declaredFields.map { it.name }).doesNotContain("classAvailability") } + @Test + fun `task file round-trips into injected LoadClass availability`() { + val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("project")).build() + val output = tempDir.newFile("availability.properties") + val task = + project.tasks.register( + "glueResolveSdkClassAvailability", + ResolveSdkClassAvailabilityTask::class.java, + ) { + it.moduleIds.set(setOf("com.jakewharton.timber:timber", "androidx.core:core")) + it.outputFile.set(output) + } + + task.get().action() + + val availability = readClassAvailability(task.get().outputFile) + val clazz = load(transformClass(availability = availability)) + + assertThat(readAvailability(clazz)) + .containsAtLeast( + "timber.log.Timber", + true, + "androidx.core.view.ScrollingView", + true, + "androidx.compose.ui.node.Owner", + false, + ) + } + private fun transformClass( hasAvailabilityField: Boolean = true, hasStaticInitializer: Boolean = false, diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt index d87f2375c..fec6b1426 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt @@ -1,7 +1,9 @@ package io.sentry.android.gradle.integration +import com.google.common.truth.Truth.assertThat import io.sentry.BuildConfig import io.sentry.android.gradle.extensions.InstrumentationFeature +import io.sentry.android.gradle.findLoadClassBytecode import io.sentry.android.gradle.util.AgpVersions import io.sentry.android.gradle.util.SemVer import io.sentry.android.gradle.util.SentryVersions @@ -24,6 +26,10 @@ import org.hamcrest.CoreMatchers.`is` import org.junit.Assert.assertThrows import org.junit.Assume.assumeThat import org.junit.Test +import org.objectweb.asm.ClassReader +import org.objectweb.asm.Opcodes +import org.objectweb.asm.tree.ClassNode +import org.objectweb.asm.tree.LdcInsnNode class SentryPluginTest : BaseSentryPluginTest(BuildConfig.AgpVersion, GradleVersion.current().version) { @@ -577,6 +583,36 @@ class SentryPluginTest : ) } + @Test + fun `injects class availability into LoadClass from resolved runtime classpath`() { + // SDK must ship the classAvailability field (added in sentry-java 8.52+). + applyTracingInstrumentation( + tracingInstrumentation = false, + appStart = false, + logcat = false, + sdkVersion = BuildConfig.SdkVersion, + dependencies = setOf("com.jakewharton.timber:timber:5.0.1"), + ) + + runner.appendArguments(":app:assembleDebug").build() + + val availabilityFile = + testProjectDir.root.resolve( + "app/build/intermediates/sentry/classAvailability/Debug.properties" + ) + assertTrue(availabilityFile.isFile, "Missing availability file at ${availabilityFile.path}") + + val availability = parseAvailabilityProperties(availabilityFile) + assertThat(availability["timber.log.Timber"]).isTrue() + assertThat(availability["androidx.compose.ui.node.Owner"]).isFalse() + + val loadClassBytes = findLoadClassBytecode(testProjectDir.root.resolve("app/build")) + assertThat(readInjectedAvailability(loadClassBytes)) + .containsAtLeastEntriesIn( + mapOf("timber.log.Timber" to true, "androidx.compose.ui.node.Owner" to false) + ) + } + @Test fun `register tracing instrumentation if tracingInstrumentation is enabled`() { applyTracingInstrumentation() @@ -1380,6 +1416,56 @@ class SentryPluginTest : return line.substring(start, end).split(", ").filter { it.isNotEmpty() } } + private fun parseAvailabilityProperties(file: File): Map = + file + .readLines() + .mapNotNull { line -> + val trimmed = line.trim() + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + null + } else { + val parts = trimmed.split('=', limit = 2) + if (parts.size == 2) parts[0] to parts[1].toBooleanStrict() else null + } + } + .toMap() + + /** + * Reconstructs the injected `classAvailability` map from `LoadClass` bytecode by pairing each + * string LDC in `` with the following boolean constant. + */ + private fun readInjectedAvailability(classBytes: ByteArray): Map { + val classNode = ClassNode() + ClassReader(classBytes).accept(classNode, 0) + val clinit = + classNode.methods.firstOrNull { it.name == "" } + ?: error("LoadClass is missing ; availability was not injected") + + val availability = linkedMapOf() + val instructions = clinit.instructions.toArray() + var index = 0 + while (index < instructions.size) { + val insn = instructions[index] + if (insn is LdcInsnNode && insn.cst is String) { + val className = insn.cst as String + val boolInsn = instructions.getOrNull(index + 1) + val available = + when (boolInsn?.opcode) { + Opcodes.ICONST_1 -> true + Opcodes.ICONST_0 -> false + else -> null + } + if (available != null) { + availability[className] = available + index += 2 + continue + } + } + index++ + } + return availability + } + companion object { private const val SQLITE = "androidx.sqlite:sqlite:2.6.2" private const val SENTRY_ANDROID_SQLITE_OPEN_HELPER = "io.sentry:sentry-android-sqlite:6.21.0" From 560ec626afc97e7b20b933337e19604976cf4783 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:12:07 +0000 Subject: [PATCH 09/14] test(instrumentation): Prefer instrumented LoadClass bytecode Require the classAvailability injection marker when locating LoadClass under app/build so the E2E assertion cannot pick an uninstrumented library jar from walk order. Also search the TestKit transform cache where AsmClassesTransform writes dependency outputs. --- .../gradle/FindLoadClassBytecodeTest.kt | 98 +++++++++++++ .../io/sentry/android/gradle/TestUtils.kt | 135 +++++++++++++++--- .../gradle/integration/SentryPluginTest.kt | 50 ++----- 3 files changed, 226 insertions(+), 57 deletions(-) create mode 100644 plugin-build/src/test/kotlin/io/sentry/android/gradle/FindLoadClassBytecodeTest.kt diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/FindLoadClassBytecodeTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/FindLoadClassBytecodeTest.kt new file mode 100644 index 000000000..439c8b889 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/FindLoadClassBytecodeTest.kt @@ -0,0 +1,98 @@ +package io.sentry.android.gradle + +import com.google.common.truth.Truth.assertThat +import io.sentry.android.gradle.instrumentation.LoadClassClassVisitor +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes + +class FindLoadClassBytecodeTest { + + @get:Rule val tempDir = TemporaryFolder() + + @Test + fun `prefers instrumented LoadClass over uninstrumented walk order`() { + val root = tempDir.newFolder("app-build") + + // Uninstrumented copy first in walk order (plain class file under a non-asm path). + val uninstrumentedDir = + root.resolve("intermediates/runtime_library_classes_dir/debug/io/sentry/util").apply { + mkdirs() + } + uninstrumentedDir.resolve("LoadClass.class").writeBytes(originalLoadClassBytes()) + + // Instrumented copy later, nested under an asm-looking path + jar. + val instrumentedJarDir = + root.resolve("intermediates/asm_instrumented_jars/debug").apply { mkdirs() } + val instrumentedBytes = + instrumentedLoadClassBytes( + mapOf("timber.log.Timber" to true, "androidx.compose.ui.node.Owner" to false) + ) + writeJarWithEntry( + instrumentedJarDir.resolve("sentry-android-core.jar"), + "io/sentry/util/LoadClass.class", + instrumentedBytes, + ) + + val found = findLoadClassBytecode(root) + assertThat(hasClassAvailabilityInjection(found)).isTrue() + assertThat(readInjectedAvailability(found)) + .containsAtLeastEntriesIn( + mapOf("timber.log.Timber" to true, "androidx.compose.ui.node.Owner" to false) + ) + } + + @Test + fun `errors when only uninstrumented LoadClass is present`() { + val root = tempDir.newFolder("only-uninstrumented") + val dir = root.resolve("io/sentry/util").apply { mkdirs() } + dir.resolve("LoadClass.class").writeBytes(originalLoadClassBytes()) + + val error = runCatching { findLoadClassBytecode(root) }.exceptionOrNull() + assertThat(error).isInstanceOf(IllegalStateException::class.java) + assertThat(error!!.message).contains("Candidates without injection marker") + } + + private fun originalLoadClassBytes(): ByteArray { + val writer = ClassWriter(0) + writer.visit( + Opcodes.V1_8, + Opcodes.ACC_PUBLIC, + "io/sentry/util/LoadClass", + null, + "java/lang/Object", + null, + ) + writer + .visitField( + Opcodes.ACC_PRIVATE or Opcodes.ACC_STATIC, + "classAvailability", + "Ljava/util/Map;", + null, + null, + ) + .visitEnd() + writer.visitEnd() + return writer.toByteArray() + } + + private fun instrumentedLoadClassBytes(availability: Map): ByteArray { + val reader = ClassReader(originalLoadClassBytes()) + val writer = ClassWriter(reader, ClassWriter.COMPUTE_FRAMES or ClassWriter.COMPUTE_MAXS) + reader.accept(LoadClassClassVisitor(Opcodes.ASM9, writer, availability), 0) + return writer.toByteArray() + } + + private fun writeJarWithEntry(jar: java.io.File, entryName: String, bytes: ByteArray) { + JarOutputStream(jar.outputStream()).use { jos -> + jos.putNextEntry(JarEntry(entryName)) + jos.write(bytes) + jos.closeEntry() + } + } +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt index 9fda84fa0..e73cf7259 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt @@ -19,6 +19,12 @@ import net.lingala.zip4j.exception.ZipException import net.lingala.zip4j.io.inputstream.ZipInputStream import org.gradle.api.Project import org.junit.rules.TemporaryFolder +import org.objectweb.asm.ClassReader +import org.objectweb.asm.Opcodes +import org.objectweb.asm.tree.ClassNode +import org.objectweb.asm.tree.FieldInsnNode +import org.objectweb.asm.tree.LdcInsnNode +import org.objectweb.asm.tree.TypeInsnNode private val ASSET_PATTERN_PROGUARD = Regex( @@ -184,29 +190,126 @@ internal fun extractZipBytes(zipFile: File, fileToExtract: String): ByteArray? { } } -/** Finds instrumented `io/sentry/util/LoadClass.class` under a build directory. */ -internal fun findLoadClassBytecode(rootFile: File): ByteArray { +/** + * Finds ASM-instrumented `io/sentry/util/LoadClass.class` under one or more roots. + * + * AGP keeps uninstrumented library jars/class dirs under `app/build` + * (`runtime_library_classes_jar`, compile jars, etc.) and writes dependency instrumentation into + * Gradle artifact-transform outputs. Never return the first path match — require the + * `classAvailability` injection marker, and prefer paths that look like ASM outputs when several + * instrumented copies exist. + */ +internal fun findLoadClassBytecode(vararg searchRoots: File): ByteArray { val classRelativePath = "io/sentry/util/LoadClass.class" - val classFile = - rootFile.walkTopDown().firstOrNull { file -> - file.isFile && - file.name == "LoadClass.class" && - file.invariantSeparatorsPath.endsWith(classRelativePath) + val candidates = mutableListOf>() + + for (root in searchRoots) { + if (!root.exists()) continue + root.walkTopDown().forEach { file -> + if (!file.isFile) return@forEach + val path = file.invariantSeparatorsPath + when { + file.name == "LoadClass.class" && path.endsWith(classRelativePath) -> + candidates += path to file.readBytes() + file.extension.equals("jar", ignoreCase = true) -> + extractZipBytes(file, classRelativePath)?.let { candidates += path to it } + } } - if (classFile != null) { - return classFile.readBytes() } - rootFile - .walkTopDown() - .filter { it.isFile && it.extension == "jar" } - .forEach { jar -> - extractZipBytes(jar, classRelativePath)?.let { - return it + val instrumented = + candidates + .filter { (_, bytes) -> hasClassAvailabilityInjection(bytes) } + .sortedByDescending { (path, _) -> isLikelyAsmInstrumentedPath(path) } + + return instrumented.firstOrNull()?.second + ?: error( + buildString { + append("Could not find instrumented $classRelativePath under ") + append(searchRoots.joinToString { it.absolutePath }) + if (candidates.isNotEmpty()) { + append(". Candidates without injection marker: ") + append(candidates.joinToString { it.first }) + } + } + ) +} + +/** + * True when [LoadClassClassVisitor] injected `classAvailability = new HashMap<>()` into ``. + */ +internal fun hasClassAvailabilityInjection(classBytes: ByteArray): Boolean { + val classNode = ClassNode() + ClassReader(classBytes).accept(classNode, 0) + val clinit = classNode.methods.firstOrNull { it.name == "" } ?: return false + + var sawHashMap = false + var sawPutStatic = false + for (insn in clinit.instructions) { + when (insn) { + is TypeInsnNode -> { + if (insn.opcode == Opcodes.NEW && insn.desc == "java/util/HashMap") { + sawHashMap = true + } + } + is FieldInsnNode -> { + if ( + insn.opcode == Opcodes.PUTSTATIC && + insn.owner == "io/sentry/util/LoadClass" && + insn.name == "classAvailability" + ) { + sawPutStatic = true + } } } + } + return sawHashMap && sawPutStatic +} + +/** + * Reconstructs the injected `classAvailability` map from `LoadClass` bytecode by pairing each + * string LDC in `` with the following boolean constant. + */ +internal fun readInjectedAvailability(classBytes: ByteArray): Map { + val classNode = ClassNode() + ClassReader(classBytes).accept(classNode, 0) + val clinit = + classNode.methods.firstOrNull { it.name == "" } + ?: error("LoadClass is missing ; availability was not injected") + + val availability = linkedMapOf() + val instructions = clinit.instructions.toArray() + var index = 0 + while (index < instructions.size) { + val insn = instructions[index] + if (insn is LdcInsnNode && insn.cst is String) { + val className = insn.cst as String + val boolInsn = instructions.getOrNull(index + 1) + val available = + when (boolInsn?.opcode) { + Opcodes.ICONST_1 -> true + Opcodes.ICONST_0 -> false + else -> null + } + if (available != null) { + availability[className] = available + index += 2 + continue + } + } + index++ + } + return availability +} - error("Could not find instrumented $classRelativePath under ${rootFile.absolutePath}") +private fun isLikelyAsmInstrumentedPath(path: String): Boolean { + val normalized = path.lowercase() + return "asm_instrumented" in normalized || + "instrumented_classes" in normalized || + "classeswithasm" in normalized || + // Artifact-transform outputs for dependency jars (AsmClassesTransform). + (Regex("/transforms-\\d+/").containsMatchIn(normalized) && + ("asm" in normalized || "instrument" in normalized)) } private fun readZippedContent(zipInputStream: ZipInputStream): String { diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt index fec6b1426..d3bd97f42 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import io.sentry.BuildConfig import io.sentry.android.gradle.extensions.InstrumentationFeature import io.sentry.android.gradle.findLoadClassBytecode +import io.sentry.android.gradle.readInjectedAvailability import io.sentry.android.gradle.util.AgpVersions import io.sentry.android.gradle.util.SemVer import io.sentry.android.gradle.util.SentryVersions @@ -26,10 +27,6 @@ import org.hamcrest.CoreMatchers.`is` import org.junit.Assert.assertThrows import org.junit.Assume.assumeThat import org.junit.Test -import org.objectweb.asm.ClassReader -import org.objectweb.asm.Opcodes -import org.objectweb.asm.tree.ClassNode -import org.objectweb.asm.tree.LdcInsnNode class SentryPluginTest : BaseSentryPluginTest(BuildConfig.AgpVersion, GradleVersion.current().version) { @@ -606,7 +603,14 @@ class SentryPluginTest : assertThat(availability["timber.log.Timber"]).isTrue() assertThat(availability["androidx.compose.ui.node.Owner"]).isFalse() - val loadClassBytes = findLoadClassBytecode(testProjectDir.root.resolve("app/build")) + // Search project build outputs and the TestKit transform cache. Dependency jars are + // instrumented via AsmClassesTransform, so the injected class may only exist under + // transforms-*/... rather than app/build intermediates. + val loadClassBytes = + findLoadClassBytecode( + testProjectDir.root.resolve("app/build"), + File("build/tmp/integrationTest/work/.gradle-test-kit"), + ) assertThat(readInjectedAvailability(loadClassBytes)) .containsAtLeastEntriesIn( mapOf("timber.log.Timber" to true, "androidx.compose.ui.node.Owner" to false) @@ -1430,42 +1434,6 @@ class SentryPluginTest : } .toMap() - /** - * Reconstructs the injected `classAvailability` map from `LoadClass` bytecode by pairing each - * string LDC in `` with the following boolean constant. - */ - private fun readInjectedAvailability(classBytes: ByteArray): Map { - val classNode = ClassNode() - ClassReader(classBytes).accept(classNode, 0) - val clinit = - classNode.methods.firstOrNull { it.name == "" } - ?: error("LoadClass is missing ; availability was not injected") - - val availability = linkedMapOf() - val instructions = clinit.instructions.toArray() - var index = 0 - while (index < instructions.size) { - val insn = instructions[index] - if (insn is LdcInsnNode && insn.cst is String) { - val className = insn.cst as String - val boolInsn = instructions.getOrNull(index + 1) - val available = - when (boolInsn?.opcode) { - Opcodes.ICONST_1 -> true - Opcodes.ICONST_0 -> false - else -> null - } - if (available != null) { - availability[className] = available - index += 2 - continue - } - } - index++ - } - return availability - } - companion object { private const val SQLITE = "androidx.sqlite:sqlite:2.6.2" private const val SENTRY_ANDROID_SQLITE_OPEN_HELPER = "io.sentry:sentry-android-sqlite:6.21.0" From 10c9a2c361ac48931fe49c7d13ff3fca710da3b1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:46:12 +0000 Subject: [PATCH 10/14] test(instrumentation): Match expected LoadClass availability map CI showed the finder could return a stale instrumented jar from the shared TestKit transform cache (timber=false) even when this build's availability file had timber=true. Filter candidates by the expected map and prefer fresher ASM outputs. --- .../gradle/FindLoadClassBytecodeTest.kt | 29 ++++++++++ .../io/sentry/android/gradle/TestUtils.kt | 55 +++++++++++++++---- .../gradle/integration/SentryPluginTest.kt | 11 ++-- 3 files changed, 78 insertions(+), 17 deletions(-) diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/FindLoadClassBytecodeTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/FindLoadClassBytecodeTest.kt index 439c8b889..7fd541e1b 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/FindLoadClassBytecodeTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/FindLoadClassBytecodeTest.kt @@ -58,6 +58,35 @@ class FindLoadClassBytecodeTest { assertThat(error!!.message).contains("Candidates without injection marker") } + @Test + fun `skips stale instrumented copies that do not match expected availability`() { + val root = tempDir.newFolder("stale-cache") + val jars = root.resolve("intermediates/asm_instrumented_jars/debug").apply { mkdirs() } + + // Older transform output from a previous test (no timber on classpath). + writeJarWithEntry( + jars.resolve("stale-sentry.jar"), + "io/sentry/util/LoadClass.class", + instrumentedLoadClassBytes( + mapOf("timber.log.Timber" to false, "androidx.compose.ui.node.Owner" to false) + ), + ) + // Fresh transform output for this build. + val fresh = + instrumentedLoadClassBytes( + mapOf("timber.log.Timber" to true, "androidx.compose.ui.node.Owner" to false) + ) + writeJarWithEntry(jars.resolve("fresh-sentry.jar"), "io/sentry/util/LoadClass.class", fresh) + + val found = + findLoadClassBytecode( + root, + expectedAvailability = + mapOf("timber.log.Timber" to true, "androidx.compose.ui.node.Owner" to false), + ) + assertThat(readInjectedAvailability(found)["timber.log.Timber"]).isTrue() + } + private fun originalLoadClassBytes(): ByteArray { val writer = ClassWriter(0) writer.visit( diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt index e73cf7259..1ca7fcee1 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt @@ -195,13 +195,17 @@ internal fun extractZipBytes(zipFile: File, fileToExtract: String): ByteArray? { * * AGP keeps uninstrumented library jars/class dirs under `app/build` * (`runtime_library_classes_jar`, compile jars, etc.) and writes dependency instrumentation into - * Gradle artifact-transform outputs. Never return the first path match — require the - * `classAvailability` injection marker, and prefer paths that look like ASM outputs when several - * instrumented copies exist. + * Gradle artifact-transform outputs. The shared TestKit transform cache can also retain older + * instrumented copies from other tests. Never return the first path match — require the + * `classAvailability` injection marker, optionally match [expectedAvailability], and prefer fresher + * ASM/transform outputs when several instrumented copies exist. */ -internal fun findLoadClassBytecode(vararg searchRoots: File): ByteArray { +internal fun findLoadClassBytecode( + vararg searchRoots: File, + expectedAvailability: Map = emptyMap(), +): ByteArray { val classRelativePath = "io/sentry/util/LoadClass.class" - val candidates = mutableListOf>() + val candidates = mutableListOf() for (root in searchRoots) { if (!root.exists()) continue @@ -210,31 +214,58 @@ internal fun findLoadClassBytecode(vararg searchRoots: File): ByteArray { val path = file.invariantSeparatorsPath when { file.name == "LoadClass.class" && path.endsWith(classRelativePath) -> - candidates += path to file.readBytes() + candidates += LoadClassCandidate(path, file.lastModified(), file.readBytes()) file.extension.equals("jar", ignoreCase = true) -> - extractZipBytes(file, classRelativePath)?.let { candidates += path to it } + extractZipBytes(file, classRelativePath)?.let { + candidates += LoadClassCandidate(path, file.lastModified(), it) + } } } } val instrumented = candidates - .filter { (_, bytes) -> hasClassAvailabilityInjection(bytes) } - .sortedByDescending { (path, _) -> isLikelyAsmInstrumentedPath(path) } + .filter { hasClassAvailabilityInjection(it.bytes) } + .filter { candidate -> + expectedAvailability.isEmpty() || + readInjectedAvailability(candidate.bytes) + .entries + .containsAll(expectedAvailability.entries) + } + .sortedWith( + compareByDescending { isLikelyAsmInstrumentedPath(it.path) } + .thenByDescending { it.lastModifiedMs } + ) - return instrumented.firstOrNull()?.second + return instrumented.firstOrNull()?.bytes ?: error( buildString { append("Could not find instrumented $classRelativePath under ") append(searchRoots.joinToString { it.absolutePath }) - if (candidates.isNotEmpty()) { + if (expectedAvailability.isNotEmpty()) { + append(" matching expected availability $expectedAvailability") + } + val injected = + candidates + .filter { hasClassAvailabilityInjection(it.bytes) } + .map { "${it.path} -> ${readInjectedAvailability(it.bytes)}" } + if (injected.isNotEmpty()) { + append(". Instrumented candidates: ") + append(injected.joinToString()) + } else if (candidates.isNotEmpty()) { append(". Candidates without injection marker: ") - append(candidates.joinToString { it.first }) + append(candidates.joinToString { it.path }) } } ) } +private data class LoadClassCandidate( + val path: String, + val lastModifiedMs: Long, + val bytes: ByteArray, +) + /** * True when [LoadClassClassVisitor] injected `classAvailability = new HashMap<>()` into ``. */ diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt index d3bd97f42..0b17cb84c 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginTest.kt @@ -605,16 +605,17 @@ class SentryPluginTest : // Search project build outputs and the TestKit transform cache. Dependency jars are // instrumented via AsmClassesTransform, so the injected class may only exist under - // transforms-*/... rather than app/build intermediates. + // transforms-*/... rather than app/build intermediates. Match the properties-file map so + // a stale instrumented jar from another TestKit run cannot win on walk order. + val expectedInjected = + mapOf("timber.log.Timber" to true, "androidx.compose.ui.node.Owner" to false) val loadClassBytes = findLoadClassBytecode( testProjectDir.root.resolve("app/build"), File("build/tmp/integrationTest/work/.gradle-test-kit"), + expectedAvailability = expectedInjected, ) - assertThat(readInjectedAvailability(loadClassBytes)) - .containsAtLeastEntriesIn( - mapOf("timber.log.Timber" to true, "androidx.compose.ui.node.Owner" to false) - ) + assertThat(readInjectedAvailability(loadClassBytes)).containsAtLeastEntriesIn(expectedInjected) } @Test From 4a568a895fce2ff49d7bce7c3e49344ce63c8a87 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:27:28 +0000 Subject: [PATCH 11/14] fix(instrumentation): Pass availability as MapProperty to ASM Nested @InputFile visitor params are not reliable for dependency jars: AGP instruments them via isolated AsmClassesTransform, which does not promote nested file inputs into transform inputs/dependencies. CI showed the resolve task wrote timber=true while every instrumented LoadClass candidate had an empty or timber=false map. Restore a serializable @Input MapProperty and populate it from the resolve task output so workers receive the availability values without config-time classpath resolution. --- .../android/gradle/AndroidComponentsConfig.kt | 13 +++++-- ...entrySdkOptimizationClassVisitorFactory.kt | 35 +++++++++++++------ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt index cc8aa6497..c9f07293e 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt @@ -21,6 +21,7 @@ import io.sentry.android.gradle.SentryTasksProvider.getMappingFileProvider import io.sentry.android.gradle.extensions.SentryPluginExtension import io.sentry.android.gradle.instrumentation.SentrySdkOptimizationClassVisitorFactory import io.sentry.android.gradle.instrumentation.SpanAddingClassVisitorFactory +import io.sentry.android.gradle.instrumentation.readClassAvailability import io.sentry.android.gradle.services.SentryModulesService import io.sentry.android.gradle.snapshot.GenerateSnapshotTestsTask import io.sentry.android.gradle.sourcecontext.OutputPaths @@ -184,7 +185,10 @@ fun ApplicationAndroidComponentsExtension.configure( // Runtime optimizations need the dependency graph as an instrumentation @Input. Reading // resolutionResult while building params would resolve *RuntimeClasspath at configuration - // time, so a task produces the availability file and that output is wired in as the input. + // time, so a task produces the availability map and that output is mapped into a serializable + // MapProperty. Nested @InputFile params are not reliable for dependency jars: AGP instruments + // them via isolated AsmClassesTransform, which does not promote visitor file inputs into + // transform inputs/dependencies. if (runtimeOptimizationsEnabled) { val availabilityTask = ResolveSdkClassAvailabilityTask.register( @@ -199,8 +203,11 @@ fun ApplicationAndroidComponentsExtension.configure( SentrySdkOptimizationClassVisitorFactory::class.java, InstrumentationScope.ALL, ) { params -> - params.classAvailabilityFile.setDisallowChanges( - availabilityTask.flatMap { it.outputFile } + // Map the task output into an @Input MapProperty so AsmClassesTransform workers receive + // the availability values. TaskProvider.map depends on the task output, so the file is + // read only after the resolve task runs (not at configuration time). + params.classAvailability.setDisallowChanges( + availabilityTask.map { task -> readClassAvailability(task.outputFile.get().asFile) } ) } variant.instrumentation.setAsmFramesComputationMode( diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt index 41707017c..2f161323e 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt @@ -5,13 +5,14 @@ import com.android.build.api.instrumentation.ClassContext import com.android.build.api.instrumentation.ClassData import com.android.build.api.instrumentation.InstrumentationParameters import io.sentry.android.gradle.util.SentryModules +import java.io.File import java.util.Properties import org.gradle.api.artifacts.ModuleIdentifier +import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty import org.gradle.api.internal.artifacts.DefaultModuleIdentifier -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.provider.MapProperty +import org.gradle.api.tasks.Input import org.objectweb.asm.ClassVisitor abstract class SentrySdkOptimizationClassVisitorFactory : @@ -19,14 +20,16 @@ abstract class SentrySdkOptimizationClassVisitorFactory : interface SdkOptimizationParameters : InstrumentationParameters { /** - * Properties file produced by - * [io.sentry.android.gradle.tasks.dependencies.ResolveSdkClassAvailabilityTask]. Using a file - * input keeps classpath resolution on the producing task and lets AGP invalidate - * instrumentation when dependencies change. + * Optional SDK class availability produced by + * [io.sentry.android.gradle.tasks.dependencies.ResolveSdkClassAvailabilityTask]. + * + * This must be a serializable [MapProperty] (not a nested `@InputFile`). Dependency jars are + * instrumented via AGP's isolated `AsmClassesTransform`, which does not promote nested visitor + * file inputs into transform inputs/dependencies — a file param is often missing/empty in the + * worker and yields a blank injection map. The map is still populated from the task output + * through a mapped provider so classpath resolution stays on the task. */ - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - val classAvailabilityFile: RegularFileProperty + @get:Input val classAvailability: MapProperty } override fun createClassVisitor( @@ -36,10 +39,13 @@ abstract class SentrySdkOptimizationClassVisitorFactory : return LoadClassClassVisitor( instrumentationContext.apiVersion.get(), nextClassVisitor, - readClassAvailability(parameters.get().classAvailabilityFile), + parameters.get().classAvailability.get(), ) } + // Don't gate on classAvailability.isNotEmpty() here: AGP may probe isInstrumentable while + // snapshotting params, before the resolve task has produced values. An early empty read would + // permanently skip LoadClass. createClassVisitor always sees the realized map. override fun isInstrumentable(classData: ClassData): Boolean = classData.className == LOAD_CLASS_NAME @@ -84,6 +90,13 @@ internal fun resolveClassAvailability(modules: Set): Map { val file = fileProperty.orNull?.asFile ?: return emptyMap() + return readClassAvailability(file) +} + +internal fun readClassAvailability(file: RegularFile): Map = + readClassAvailability(file.asFile) + +internal fun readClassAvailability(file: File): Map { if (!file.isFile || file.length() == 0L) { return emptyMap() } From ebdf5d4ed1550004a858b27f098838c9f4934bea Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:34:20 +0000 Subject: [PATCH 12/14] fix(instrumentation): Depend on availability task output via flatMap TaskProvider.map { task -> task.outputFile.get() } can read the file before the resolve task runs and snapshot an empty map into the ASM worker. Wire classAvailability from availabilityTask.flatMap { outputFile }.map(::readClassAvailability) so Gradle tracks the produced file and only reads it after execution. --- .../sentry/android/gradle/AndroidComponentsConfig.kt | 9 ++++++--- .../SentrySdkOptimizationClassVisitorFactory.kt | 11 ++++++----- .../ResolveSdkClassAvailabilityTaskTest.kt | 9 ++++++++- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt index c9f07293e..62e0afc24 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt @@ -204,10 +204,13 @@ fun ApplicationAndroidComponentsExtension.configure( InstrumentationScope.ALL, ) { params -> // Map the task output into an @Input MapProperty so AsmClassesTransform workers receive - // the availability values. TaskProvider.map depends on the task output, so the file is - // read only after the resolve task runs (not at configuration time). + // the availability values by value (isolatable). Use flatMap on outputFile — not + // TaskProvider.map { task -> task.outputFile.get() } — so Gradle depends on the + // produced file and only reads it after the resolve task executes. params.classAvailability.setDisallowChanges( - availabilityTask.map { task -> readClassAvailability(task.outputFile.get().asFile) } + availabilityTask + .flatMap { it.outputFile } + .map { file -> readClassAvailability(file.asFile) } ) } variant.instrumentation.setAsmFramesComputationMode( diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt index 2f161323e..7dfd1bbce 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt @@ -23,11 +23,12 @@ abstract class SentrySdkOptimizationClassVisitorFactory : * Optional SDK class availability produced by * [io.sentry.android.gradle.tasks.dependencies.ResolveSdkClassAvailabilityTask]. * - * This must be a serializable [MapProperty] (not a nested `@InputFile`). Dependency jars are - * instrumented via AGP's isolated `AsmClassesTransform`, which does not promote nested visitor - * file inputs into transform inputs/dependencies — a file param is often missing/empty in the - * worker and yields a blank injection map. The map is still populated from the task output - * through a mapped provider so classpath resolution stays on the task. + * Must be a serializable [MapProperty] (not a nested `@InputFile`). Dependency jars are + * instrumented via AGP's isolated `AsmClassesTransform`, which does not reliably promote nested + * visitor file inputs into transform inputs/dependencies — workers then see a missing/empty + * file and inject a blank map. Wire this from `availabilityTask.flatMap { it.outputFile + * }.map(::readClassAvailability)` so Gradle depends on the task output and only reads the file + * after the resolve task runs. */ @get:Input val classAvailability: MapProperty } diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt index 29e3aa052..d4911d50e 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt @@ -74,9 +74,16 @@ class ResolveSdkClassAvailabilityTaskTest { // Realize the task object (runs the configure action) without reading moduleIds. task.get() + // Same wiring used for the ASM MapProperty: depends on the output file provider without + // reading it. Building this chain must stay lazy (no config-time resolution). + @Suppress("UNUSED_VARIABLE") + val availabilityProvider = + task.flatMap { it.outputFile }.map { file -> readClassAvailability(file.asFile) } + // Mutation oracle: wiring the task must not force config-time resolution. Evaluating // resolutionResult during register (instead of inside a lazy Provider) would flip this to - // RESOLVED and fail the guard. + // RESOLVED and fail the guard. Likewise, TaskProvider.map { task.outputFile.get() } would + // read the file before the task runs and break the dependency edge. assertThat(configuration.state).isEqualTo(Configuration.State.UNRESOLVED) } From c2b50db5d0750ca4eee8f572d099d0db4c8d1fe7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:45:38 +0000 Subject: [PATCH 13/14] fix(instrumentation): Run availability task before AsmClassesTransform AsmClassesTransform isolates nested visitor @Input MapProperty values when dependency jars are consumed. Isolation cannot query a task-mapped provider until the producing task has completed. Make merge/dex/transform consumers depend on resolveSentrySdkClassAvailability so the map is ready. --- .../android/gradle/AndroidComponentsConfig.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt index 62e0afc24..6cb450b9e 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt @@ -213,6 +213,18 @@ fun ApplicationAndroidComponentsExtension.configure( .map { file -> readClassAvailability(file.asFile) } ) } + // AsmClassesTransform isolates nested visitor @Input values when dependency jars are + // consumed. Isolation cannot query a task-mapped MapProperty until the producing task + // has completed (Gradle error: "Querying the mapped value ... before task has + // completed is not supported"). Force the availability task to finish before variant + // consumers that resolve instrumented runtime jars and trigger those transforms. + val variantSuffix = variant.name.capitalized + project.tasks.configureEach { task -> + val name = task.name + if (shouldRunSdkClassAvailabilityBefore(name, variantSuffix)) { + task.dependsOn(availabilityTask) + } + } variant.instrumentation.setAsmFramesComputationMode( FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS ) @@ -599,6 +611,25 @@ fun Variant.configureUploadAppTasks( return uploadBundleTask to uploadApkTask } +/** + * Tasks that consume instrumented dependency jars and cause AGP to isolate AsmClassesTransform + * parameters. The SDK class-availability resolve task must complete before these run so nested + * visitor MapProperty inputs are queryable during isolation. + */ +private fun shouldRunSdkClassAvailabilityBefore(taskName: String, variantSuffix: String): Boolean { + if (taskName == "pre${variantSuffix}Build") return true + if (taskName == "transform${variantSuffix}ClassesWithAsm") return true + // Dex / merge tasks that pull runtime classpath jars through AsmClassesTransform. + if (!taskName.contains(variantSuffix)) return false + return taskName.startsWith("mergeExtDex") || + taskName.startsWith("mergeLibDex") || + taskName.startsWith("mergeProjectDex") || + taskName.startsWith("mergeDex") || + taskName.startsWith("dexBuilder") || + (taskName.startsWith("check") && taskName.contains("DuplicateClasses")) || + taskName.contains("ClassesWithAsm") +} + private fun Variant.configureInstrumentation( classVisitorFactoryImplClass: Class>, scope: InstrumentationScope, From a511985de234add093214b01c93d7fddffc656b5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:55:11 +0000 Subject: [PATCH 14/14] fix(instrumentation): Use @InputFile for availability, not MapProperty AsmClassesTransform isolation cannot query task-mapped nested @Input MapProperty values (even after dependsOn ordered the resolve task). Restore RegularFileProperty @InputFile wiring and keep merge/dex/ transform consumers depending on the resolve task so the file exists when workers run. --- .../android/gradle/AndroidComponentsConfig.kt | 30 +++++++------------ ...entrySdkOptimizationClassVisitorFactory.kt | 30 ++++++++++--------- .../ResolveSdkClassAvailabilityTaskTest.kt | 9 ++---- 3 files changed, 29 insertions(+), 40 deletions(-) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt index 6cb450b9e..2c8ef6357 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt @@ -21,7 +21,6 @@ import io.sentry.android.gradle.SentryTasksProvider.getMappingFileProvider import io.sentry.android.gradle.extensions.SentryPluginExtension import io.sentry.android.gradle.instrumentation.SentrySdkOptimizationClassVisitorFactory import io.sentry.android.gradle.instrumentation.SpanAddingClassVisitorFactory -import io.sentry.android.gradle.instrumentation.readClassAvailability import io.sentry.android.gradle.services.SentryModulesService import io.sentry.android.gradle.snapshot.GenerateSnapshotTestsTask import io.sentry.android.gradle.sourcecontext.OutputPaths @@ -185,10 +184,10 @@ fun ApplicationAndroidComponentsExtension.configure( // Runtime optimizations need the dependency graph as an instrumentation @Input. Reading // resolutionResult while building params would resolve *RuntimeClasspath at configuration - // time, so a task produces the availability map and that output is mapped into a serializable - // MapProperty. Nested @InputFile params are not reliable for dependency jars: AGP instruments - // them via isolated AsmClassesTransform, which does not promote visitor file inputs into - // transform inputs/dependencies. + // time, so a task produces an availability file and that output is wired as `@InputFile`. + // Do not map the file into a MapProperty for visitor params: AsmClassesTransform isolation + // cannot query task-mapped nested @Input values ("Querying the mapped value of flatmap... + // before task has completed is not supported"). if (runtimeOptimizationsEnabled) { val availabilityTask = ResolveSdkClassAvailabilityTask.register( @@ -203,21 +202,12 @@ fun ApplicationAndroidComponentsExtension.configure( SentrySdkOptimizationClassVisitorFactory::class.java, InstrumentationScope.ALL, ) { params -> - // Map the task output into an @Input MapProperty so AsmClassesTransform workers receive - // the availability values by value (isolatable). Use flatMap on outputFile — not - // TaskProvider.map { task -> task.outputFile.get() } — so Gradle depends on the - // produced file and only reads it after the resolve task executes. - params.classAvailability.setDisallowChanges( - availabilityTask - .flatMap { it.outputFile } - .map { file -> readClassAvailability(file.asFile) } + params.classAvailabilityFile.setDisallowChanges( + availabilityTask.flatMap { it.outputFile } ) } - // AsmClassesTransform isolates nested visitor @Input values when dependency jars are - // consumed. Isolation cannot query a task-mapped MapProperty until the producing task - // has completed (Gradle error: "Querying the mapped value ... before task has - // completed is not supported"). Force the availability task to finish before variant - // consumers that resolve instrumented runtime jars and trigger those transforms. + // Keep the resolve task ahead of consumers that pull instrumented runtime jars through + // AsmClassesTransform, so the @InputFile exists when workers run. val variantSuffix = variant.name.capitalized project.tasks.configureEach { task -> val name = task.name @@ -613,8 +603,8 @@ fun Variant.configureUploadAppTasks( /** * Tasks that consume instrumented dependency jars and cause AGP to isolate AsmClassesTransform - * parameters. The SDK class-availability resolve task must complete before these run so nested - * visitor MapProperty inputs are queryable during isolation. + * parameters. The SDK class-availability resolve task must complete before these run so the nested + * visitor `@InputFile` exists when workers run. */ private fun shouldRunSdkClassAvailabilityBefore(taskName: String, variantSuffix: String): Boolean { if (taskName == "pre${variantSuffix}Build") return true diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt index 7dfd1bbce..a5e779a28 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt @@ -11,8 +11,9 @@ import org.gradle.api.artifacts.ModuleIdentifier import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty import org.gradle.api.internal.artifacts.DefaultModuleIdentifier -import org.gradle.api.provider.MapProperty -import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity import org.objectweb.asm.ClassVisitor abstract class SentrySdkOptimizationClassVisitorFactory : @@ -20,17 +21,19 @@ abstract class SentrySdkOptimizationClassVisitorFactory : interface SdkOptimizationParameters : InstrumentationParameters { /** - * Optional SDK class availability produced by + * Properties file produced by * [io.sentry.android.gradle.tasks.dependencies.ResolveSdkClassAvailabilityTask]. * - * Must be a serializable [MapProperty] (not a nested `@InputFile`). Dependency jars are - * instrumented via AGP's isolated `AsmClassesTransform`, which does not reliably promote nested - * visitor file inputs into transform inputs/dependencies — workers then see a missing/empty - * file and inject a blank map. Wire this from `availabilityTask.flatMap { it.outputFile - * }.map(::readClassAvailability)` so Gradle depends on the task output and only reads the file - * after the resolve task runs. + * Must stay a task-output [RegularFileProperty] (`@InputFile`), not a task-mapped + * `MapProperty`. AGP instruments dependency jars via isolated `AsmClassesTransform`, and Gradle + * refuses to isolate nested visitor `@Input` values that are mapped from unfinished (or even + * finished) task providers: `Querying the mapped value of flatmap(provider(task ...)) before + * task has completed is not supported`. A file input is isolatable; consumers that trigger + * those transforms must `dependsOn` the resolve task so the file exists when workers run. */ - @get:Input val classAvailability: MapProperty + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + val classAvailabilityFile: RegularFileProperty } override fun createClassVisitor( @@ -40,13 +43,12 @@ abstract class SentrySdkOptimizationClassVisitorFactory : return LoadClassClassVisitor( instrumentationContext.apiVersion.get(), nextClassVisitor, - parameters.get().classAvailability.get(), + readClassAvailability(parameters.get().classAvailabilityFile), ) } - // Don't gate on classAvailability.isNotEmpty() here: AGP may probe isInstrumentable while - // snapshotting params, before the resolve task has produced values. An early empty read would - // permanently skip LoadClass. createClassVisitor always sees the realized map. + // Do not read the availability file here: AGP may probe isInstrumentable while snapshotting + // params, before the resolve task has produced the file. override fun isInstrumentable(classData: ClassData): Boolean = classData.className == LOAD_CLASS_NAME diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt index d4911d50e..c9747ca3e 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt @@ -74,16 +74,13 @@ class ResolveSdkClassAvailabilityTaskTest { // Realize the task object (runs the configure action) without reading moduleIds. task.get() - // Same wiring used for the ASM MapProperty: depends on the output file provider without + // Same wiring used for the ASM @InputFile: depends on the output file provider without // reading it. Building this chain must stay lazy (no config-time resolution). - @Suppress("UNUSED_VARIABLE") - val availabilityProvider = - task.flatMap { it.outputFile }.map { file -> readClassAvailability(file.asFile) } + @Suppress("UNUSED_VARIABLE") val availabilityFileProvider = task.flatMap { it.outputFile } // Mutation oracle: wiring the task must not force config-time resolution. Evaluating // resolutionResult during register (instead of inside a lazy Provider) would flip this to - // RESOLVED and fail the guard. Likewise, TaskProvider.map { task.outputFile.get() } would - // read the file before the task runs and break the dependency edge. + // RESOLVED and fail the guard. assertThat(configuration.state).isEqualTo(Configuration.State.UNRESOLVED) }