Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 <T : InstrumentationParameters> Variant.configureInstrumentation(
classVisitorFactoryImplClass: Class<out AsmClassVisitorFactory<T>>,
scope: InstrumentationScope,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SentrySdkOptimizationClassVisitorFactory.SdkOptimizationParameters> {

interface SdkOptimizationParameters : InstrumentationParameters {
@get:Input val classAvailability: MapProperty<String, Boolean>
/**
* 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(
Expand All @@ -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"
Expand Down Expand Up @@ -72,3 +90,23 @@ internal fun resolveClassAvailability(modules: Set<ModuleIdentifier>): Map<Strin
SentrySdkOptimizationClassVisitorFactory.CLASS_MODULES.mapValues { (_, owners) ->
owners.any { it in modules }
}

internal fun readClassAvailability(fileProperty: RegularFileProperty): Map<String, Boolean> {
val file = fileProperty.orNull?.asFile ?: return emptyMap()
return readClassAvailability(file)
}

internal fun readClassAvailability(file: RegularFile): Map<String, Boolean> =
readClassAvailability(file.asFile)

internal fun readClassAvailability(file: File): Map<String, Boolean> {
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()
}
}
Original file line number Diff line number Diff line change
@@ -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<String>

@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 ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a comment here or somewhere as to what we expect the file to look like?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added above the writer:

// Deterministic one-entry-per-line properties (no java.util.Properties timestamp header):
//   androidx.core.view.ScrollingView=true
//   timber.log.Timber=false

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<ResolveSdkClassAvailabilityTask>? {
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()
}
Comment thread
cursor[bot] marked this conversation as resolved.
)
task.outputFile.set(
project.layout.buildDirectory.file(
"intermediates/sentry/classAvailability/${taskSuffix}.properties"
)
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ fun Project.collectModules(
configurationName: String,
variantName: String,
sentryModulesService: Provider<SentryModulesService>,
): Provider<Set<ModuleIdentifier>> {
) {
val configProvider =
try {
configurations.named(configurationName)
} catch (e: UnknownDomainObjectException) {
logger.warn { "Unable to find configuration $configurationName for variant $variantName." }
sentryModulesService.get().sentryModules = emptyMap()
sentryModulesService.get().externalModules = emptyMap()
return provider<Set<ModuleIdentifier>> { 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)
Expand All @@ -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<ResolvedComponentResult>.versionMap(
Expand Down
Loading
Loading