diff --git a/CHANGELOG.md b/CHANGELOG.md index 949e9dd6c..d271cf2c0 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 ([#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()` 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..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.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,49 @@ 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 need the dependency graph as an instrumentation @Input. Reading + // resolutionResult while building params would resolve *RuntimeClasspath at configuration + // 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( + project = project, + 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, + InstrumentationScope.ALL, + ) { params -> + params.classAvailabilityFile.setDisallowChanges( + availabilityTask.flatMap { it.outputFile } + ) + } + // 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 + if (shouldRunSdkClassAvailabilityBefore(name, variantSuffix)) { + task.dependsOn(availabilityTask) + } + } + 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 +236,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, @@ -581,6 +601,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 the nested + * visitor `@InputFile` exists when workers run. + */ +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, 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..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 @@ -5,17 +5,35 @@ 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.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]. + * + * 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:InputFile + @get:PathSensitive(PathSensitivity.NONE) + val classAvailabilityFile: RegularFileProperty } override fun createClassVisitor( @@ -25,14 +43,14 @@ 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. + // 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 && parameters.get().classAvailability.get().isNotEmpty() + classData.className == LOAD_CLASS_NAME internal companion object { const val LOAD_CLASS_NAME = "io.sentry.util.LoadClass" @@ -72,3 +90,23 @@ internal fun resolveClassAvailability(modules: Set): Map owners.any { it in modules } } + +internal fun readClassAvailability(fileProperty: RegularFileProperty): 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() + } + + 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..e11ab42f6 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTask.kt @@ -0,0 +1,102 @@ +package io.sentry.android.gradle.tasks.dependencies + +import io.sentry.android.gradle.instrumentation.resolveClassAvailability +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.UnknownDomainObjectException +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" + } + + /** Module coordinates in `group:module` form from the resolved dependency graph. */ + @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 + // 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') + } + } + } + + companion object { + fun register( + project: Project, + configurationName: String, + 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 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( + project.provider { + configuration.incoming.resolutionResult.allComponents + .mapNotNull { component -> component.moduleVersion?.module } + .map { module -> "${module.group}:${module.name}" } + .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/FindLoadClassBytecodeTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/FindLoadClassBytecodeTest.kt new file mode 100644 index 000000000..7fd541e1b --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/FindLoadClassBytecodeTest.kt @@ -0,0 +1,127 @@ +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") + } + + @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( + 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 bfd86587e..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 @@ -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( @@ -175,6 +181,168 @@ 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 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. 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, + expectedAvailability: Map = emptyMap(), +): ByteArray { + val classRelativePath = "io/sentry/util/LoadClass.class" + 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 += LoadClassCandidate(path, file.lastModified(), file.readBytes()) + file.extension.equals("jar", ignoreCase = true) -> + extractZipBytes(file, classRelativePath)?.let { + candidates += LoadClassCandidate(path, file.lastModified(), it) + } + } + } + } + + val instrumented = + candidates + .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()?.bytes + ?: error( + buildString { + append("Could not find instrumented $classRelativePath under ") + append(searchRoots.joinToString { it.absolutePath }) + 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.path }) + } + } + ) +} + +private data class LoadClassCandidate( + val path: String, + val lastModifiedMs: Long, + val bytes: ByteArray, +) + +/** + * 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 +} + +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 { 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 023df2c6a..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 @@ -1,7 +1,10 @@ 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.readInjectedAvailability import io.sentry.android.gradle.util.AgpVersions import io.sentry.android.gradle.util.SemVer import io.sentry.android.gradle.util.SentryVersions @@ -563,10 +566,56 @@ 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) - assertTrue("Detected Sentry modules" 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 + 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() + + // 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. 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(expectedInjected) } @Test @@ -1372,6 +1421,20 @@ 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() + 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" 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..c9747ca3e --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/dependencies/ResolveSdkClassAvailabilityTaskTest.kt @@ -0,0 +1,96 @@ +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 +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) + } + + @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", + taskSuffix = "Test", + ) + requireNotNull(task) + // Realize the task object (runs the configure action) without reading moduleIds. + task.get() + + // 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 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. + 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 + } +} 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