diff --git a/CHANGELOG.md b/CHANGELOG.md index 159c05580..8c4776c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Avoid resolving the runtime classpath during Gradle configuration when SDK runtime optimizations are enabled ([#1404](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1404)) +### Performance + +- Resolve Sentry manifest metadata at build time to reduce Android SDK initialization overhead ([#1405](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1405)) + ### Dependencies - Bump ComposablePreviewScanner from v0.9.2 to v0.9.3 ([#1408](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1408)) 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 b2f52e7ac..64b2afee5 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 @@ -26,7 +26,6 @@ import io.sentry.android.gradle.snapshot.GenerateSnapshotTestsTask import io.sentry.android.gradle.sourcecontext.OutputPaths import io.sentry.android.gradle.sourcecontext.SourceContext import io.sentry.android.gradle.tasks.GenerateDistributionPropertiesTask -import io.sentry.android.gradle.tasks.GenerateSentryBuildTimeOptionsTask import io.sentry.android.gradle.tasks.InjectSentryMetaPropertiesIntoAssetsTask import io.sentry.android.gradle.tasks.PropertiesFileOutputTask import io.sentry.android.gradle.tasks.SentryGenerateIntegrationListTask @@ -36,6 +35,7 @@ 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.SentryExternalDependenciesReportTaskV2 +import io.sentry.android.gradle.tasks.optimization.GenerateSentryBuildTimeOptionsTask import io.sentry.android.gradle.telemetry.SentryTelemetryService import io.sentry.android.gradle.util.AgpVersions import io.sentry.android.gradle.util.GroovyCompat @@ -209,6 +209,8 @@ fun ApplicationAndroidComponentsExtension.configure( project, "${variant.name}RuntimeClasspath", variant.name.capitalized, + variant.artifacts.get(SingleArtifact.MERGED_MANIFEST), + sentryTelemetryProvider, ) val javaSources = variant.sources.java if (buildTimeOptionsTask != null && javaSources != null) { diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt new file mode 100644 index 000000000..e746dbb91 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt @@ -0,0 +1,65 @@ +package io.sentry.android.gradle + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.w3c.dom.Element + +internal object ManifestMetadataParser { + fun parse(manifest: File): Map? = + runCatching { + val document = + manifest.inputStream().buffered().use { + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(it) + } + val application = document.getElementsByTagName("application").item(0) ?: return emptyMap() + val metadata = linkedMapOf() + + for (index in 0 until application.childNodes.length) { + val element = application.childNodes.item(index) as? Element ?: continue + if (element.tagName != "meta-data") continue + + val name = element.getAttribute("android:name") + if (!name.startsWith("io.sentry.")) continue + val value = element.getAttribute("android:value") + if ( + element.hasAttribute("android:resource") || + !element.hasAttribute("android:value") || + value.startsWith("@") || + value.contains("${'$'}{") + ) { + SentryPlugin.logger.info( + "Sentry manifest metadata was not optimized because $name could not be resolved at build time." + ) + return null + } + metadata[name] = value + } + metadata + } + .onFailure { + SentryPlugin.logger.info( + "Sentry manifest metadata could not be parsed for optimization.", + it, + ) + } + .getOrNull() + + internal fun inferType(value: String): Any = + when (value) { + "true" -> true + "false" -> false + else -> parseInteger(value) ?: value.toFloatOrNull()?.takeIf { it.isFinite() } ?: value + } + + private fun parseInteger(value: String): Int? = + if (value.matches(DECIMAL_INTEGER)) { + value.toIntOrNull() + } else if (value.matches(HEX_INTEGER)) { + value.removePrefix("+").let { Integer.decode(it) } + } else { + null + } + + private val DECIMAL_INTEGER = Regex("[+-]?\\d+") + private val HEX_INTEGER = Regex("[+-]?0[xX][0-9a-fA-F]+") +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt new file mode 100644 index 000000000..9f64eb19f --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt @@ -0,0 +1,99 @@ +package io.sentry.android.gradle.instrumentation + +import io.sentry.android.gradle.SentryPlugin +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.FieldVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes + +/** Injects app-owned manifest metadata into the SDK's manifest metadata reader. */ +internal class ManifestMetadataClassVisitor(apiVersion: Int, nextClassVisitor: ClassVisitor) : + ClassVisitor(apiVersion, nextClassVisitor) { + private var hasMetadataField = false + private var hasStaticInitializer = false + + override fun visitField( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + value: Any?, + ): FieldVisitor? { + if ( + name == METADATA_FIELD && descriptor == MAP_DESCRIPTOR && access and Opcodes.ACC_STATIC != 0 + ) { + hasMetadataField = true + } + return super.visitField(access, name, descriptor, signature, value) + } + + override fun visitMethod( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + exceptions: Array?, + ): MethodVisitor { + val visitor = super.visitMethod(access, name, descriptor, signature, exceptions) + if (name != STATIC_INITIALIZER || descriptor != VOID_METHOD_DESCRIPTOR) return visitor + + hasStaticInitializer = true + return object : MethodVisitor(api, visitor) { + override fun visitInsn(opcode: Int) { + if (opcode == Opcodes.RETURN && hasMetadataField) injectMetadata(this) + super.visitInsn(opcode) + } + } + } + + override fun visitEnd() { + if (!hasMetadataField) { + SentryPlugin.logger.info( + "Sentry manifest metadata was not optimized because the current SDK version does not support this optimization." + ) + } + if (hasMetadataField && !hasStaticInitializer) { + val visitor = + super.visitMethod( + Opcodes.ACC_STATIC, + STATIC_INITIALIZER, + VOID_METHOD_DESCRIPTOR, + null, + null, + ) + visitor.visitCode() + injectMetadata(visitor) + visitor.visitInsn(Opcodes.RETURN) + visitor.visitMaxs(0, 0) + visitor.visitEnd() + } + super.visitEnd() + } + + private fun injectMetadata(visitor: MethodVisitor) { + visitor.visitMethodInsn( + Opcodes.INVOKESTATIC, + GENERATED_OPTIONS_INTERNAL_NAME, + "getManifestMetadata", + "()Ljava/util/Map;", + false, + ) + visitor.visitFieldInsn( + Opcodes.PUTSTATIC, + MANIFEST_METADATA_READER_INTERNAL_NAME, + METADATA_FIELD, + MAP_DESCRIPTOR, + ) + } + + private companion object { + const val MANIFEST_METADATA_READER_INTERNAL_NAME = + "io/sentry/android/core/ManifestMetadataReader" + const val GENERATED_OPTIONS_INTERNAL_NAME = + "io/sentry/android/core/SentryGeneratedBuildTimeOptions" + const val METADATA_FIELD = "manifestMetadata" + const val MAP_DESCRIPTOR = "Ljava/util/Map;" + const val STATIC_INITIALIZER = "" + const val VOID_METHOD_DESCRIPTOR = "()V" + } +} 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 cc6ed942f..c521f3b02 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 @@ -15,15 +15,21 @@ abstract class SentrySdkOptimizationClassVisitorFactory : override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor, - ): ClassVisitor { - return LoadClassClassVisitor(instrumentationContext.apiVersion.get(), nextClassVisitor) - } + ): ClassVisitor = + when (classContext.currentClassData.className) { + LOAD_CLASS_NAME -> + LoadClassClassVisitor(instrumentationContext.apiVersion.get(), nextClassVisitor) + MANIFEST_METADATA_READER_NAME -> + ManifestMetadataClassVisitor(instrumentationContext.apiVersion.get(), nextClassVisitor) + else -> nextClassVisitor + } override fun isInstrumentable(classData: ClassData): Boolean = - classData.className == LOAD_CLASS_NAME + classData.className == LOAD_CLASS_NAME || classData.className == MANIFEST_METADATA_READER_NAME internal companion object { const val LOAD_CLASS_NAME = "io.sentry.util.LoadClass" + const val MANIFEST_METADATA_READER_NAME = "io.sentry.android.core.ManifestMetadataReader" val CLASS_MODULES: Map> = sortedMapOf( diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt deleted file mode 100644 index 70ab8ad07..000000000 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTask.kt +++ /dev/null @@ -1,93 +0,0 @@ -package io.sentry.android.gradle.tasks - -import io.sentry.android.gradle.instrumentation.resolveClassAvailability -import org.gradle.api.Project -import org.gradle.api.UnknownDomainObjectException -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.TaskAction -import org.gradle.api.tasks.TaskProvider - -@CacheableTask -abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { - - @get:Input abstract val moduleIds: SetProperty - - @TaskAction - fun generate() { - val modules = - moduleIds - .get() - .mapNotNull { coordinate -> - val parts = coordinate.split(':', limit = 2) - if (parts.size == 2) DefaultModuleIdentifier.newId(parts[0], parts[1]) else null - } - .toSet() - val availability = resolveClassAvailability(modules) - val sourceFile = output.file(GENERATED_CLASS_PATH).get().asFile - sourceFile.parentFile.mkdirs() - sourceFile.writeText( - """ - package io.sentry.android.core; - - import java.util.HashMap; - import java.util.Map; - - public final class SentryGeneratedBuildTimeOptions { - private SentryGeneratedBuildTimeOptions() {} - - public static Map getClassAvailability() { - Map availability = new HashMap<>(); - ${ - availability.toSortedMap().entries.joinToString("\n") { (className, available) -> - " availability.put(\"$className\", $available);" - } - } - return availability; - } - } - """ - .trimIndent() + "\n" - ) - } - - companion object { - private const val GENERATED_CLASS_PATH = - "io/sentry/android/core/SentryGeneratedBuildTimeOptions.java" - - fun register( - project: Project, - configurationName: String, - taskSuffix: String, - ): TaskProvider? { - val configurationProvider = - try { - project.configurations.named(configurationName) - } catch (e: UnknownDomainObjectException) { - project.logger.warn( - "Unable to find configuration $configurationName for Sentry build-time options." - ) - return null - } - - return project.tasks.register( - "generateSentryBuildTimeOptions$taskSuffix", - GenerateSentryBuildTimeOptionsTask::class.java, - ) { task -> - task.moduleIds.set( - configurationProvider.map { configuration -> - configuration.incoming.resolutionResult.allComponents.mapNotNullTo(mutableSetOf()) { - component -> - component.moduleVersion?.module?.let { "${it.group}:${it.name}" } - } - } - ) - task.output.set( - project.layout.buildDirectory.dir("generated/sentry/buildTimeOptions/$taskSuffix") - ) - } - } - } -} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt new file mode 100644 index 000000000..5f34b29c3 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTask.kt @@ -0,0 +1,198 @@ +package io.sentry.android.gradle.tasks.optimization + +import groovy.json.JsonSlurper +import io.sentry.android.gradle.ManifestMetadataParser +import io.sentry.android.gradle.tasks.DirectoryOutputTask +import io.sentry.android.gradle.telemetry.SentryTelemetryService +import org.gradle.api.Project +import org.gradle.api.UnknownDomainObjectException +import org.gradle.api.file.RegularFile +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.TaskProvider + +@CacheableTask +abstract class GenerateSentryBuildTimeOptionsTask : DirectoryOutputTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val classAvailabilityFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val manifestMetadataFile: RegularFileProperty + + @get:Internal abstract val sentryTelemetryService: Property + + @TaskAction + fun generate() { + val json = JsonSlurper() + val availability = + (json.parseText(classAvailabilityFile.get().asFile.readText(Charsets.UTF_8)) as Map<*, *>) + .map { (key, value) -> key as String to value as Boolean } + .toMap() + val parsedMetadata = json.parseText(manifestMetadataFile.get().asFile.readText(Charsets.UTF_8)) + val metadata = + if (parsedMetadata == null) { + null + } else { + runCatching { + (parsedMetadata as Map<*, *>) + .map { (key, value) -> + key as String to ManifestMetadataParser.inferType(value as String) + } + .toMap() + } + .onFailure { + logger.info( + "Sentry manifest metadata types could not be inferred for optimization.", + it, + ) + sentryTelemetryService.orNull?.captureHandledError( + it, + "manifest metadata type inference", + ) + } + .getOrNull() + } + val sourceFile = output.file(GENERATED_CLASS_PATH).get().asFile + sourceFile.parentFile.mkdirs() + sourceFile.writeText( + """ + package io.sentry.android.core; + + import java.util.HashMap; + import java.util.Map; + + public final class SentryGeneratedBuildTimeOptions { + private SentryGeneratedBuildTimeOptions() {} + + public static Map getClassAvailability() { + Map availability = new HashMap<>(); + ${ + availability.toSortedMap().entries.joinToString("\n") { (className, available) -> + " availability.put(\"$className\", $available);" + } + } + return availability; + } + + public static Map getManifestMetadata() { + ${ + if (metadata == null) { + " return null;" + } else { + """ Map metadata = new HashMap<>(); + ${ + metadata.toSortedMap().entries.joinToString("\n") { (key, value) -> + " metadata.put(${key.javaStringLiteral()}, ${value.javaLiteral()});" + } + } + return metadata;""" + } + } + } + } + """ + .trimIndent() + "\n", + Charsets.UTF_8, + ) + } + + companion object { + private const val GENERATED_CLASS_PATH = + "io/sentry/android/core/SentryGeneratedBuildTimeOptions.java" + + private fun String.javaStringLiteral(): String = buildString { + append('"') + this@javaStringLiteral.forEach { character -> + append( + when (character) { + '\\' -> "\\\\" + '"' -> "\\\"" + '\n' -> "\\n" + '\r' -> "\\r" + '\t' -> "\\t" + else -> character + } + ) + } + append('"') + } + + private fun Any.javaLiteral(): String = + when (this) { + is Boolean, + is Int -> toString() + is Float -> "${this}f" + is String -> javaStringLiteral() + else -> error("Unsupported manifest metadata value: $this") + } + + fun register( + project: Project, + configurationName: String, + taskSuffix: String, + mergedManifest: Provider, + sentryTelemetryProvider: Provider? = null, + ): TaskProvider? { + val configurationProvider = + try { + project.configurations.named(configurationName) + } catch (e: UnknownDomainObjectException) { + project.logger.warn( + "Unable to find configuration $configurationName for Sentry build-time options." + ) + return null + } + + val intermediateDir = + project.layout.buildDirectory.dir("sentry/intermediates/buildTimeOptions/$taskSuffix") + val availabilityTask = + project.tasks.register( + "resolveSentryClassAvailability$taskSuffix", + ResolveSentryClassAvailabilityTask::class.java, + ) { task -> + task.moduleIds.set( + configurationProvider.map { configuration -> + configuration.incoming.resolutionResult.allComponents.mapNotNullTo(mutableSetOf()) { + component -> + component.moduleVersion?.module?.let { "${it.group}:${it.name}" } + } + } + ) + task.outputFile.set(intermediateDir.map { it.file("class-availability.json") }) + } + val metadataTask = + project.tasks.register( + "parseSentryManifestMetadata$taskSuffix", + ParseSentryManifestMetadataTask::class.java, + ) { task -> + task.mergedManifest.set(mergedManifest) + task.outputFile.set(intermediateDir.map { it.file("manifest-metadata.json") }) + } + + return project.tasks.register( + "generateSentryBuildTimeOptions$taskSuffix", + GenerateSentryBuildTimeOptionsTask::class.java, + ) { task -> + task.classAvailabilityFile.set(availabilityTask.flatMap { it.outputFile }) + task.manifestMetadataFile.set(metadataTask.flatMap { it.outputFile }) + task.output.set( + project.layout.buildDirectory.dir("sentry/generated/buildTimeOptions/$taskSuffix") + ) + sentryTelemetryProvider?.let { + task.sentryTelemetryService.set(it) + task.usesService(it) + } + } + } + } +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ParseSentryManifestMetadataTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ParseSentryManifestMetadataTask.kt new file mode 100644 index 000000000..121de8c8a --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ParseSentryManifestMetadataTask.kt @@ -0,0 +1,29 @@ +package io.sentry.android.gradle.tasks.optimization + +import groovy.json.JsonOutput +import io.sentry.android.gradle.ManifestMetadataParser +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +@CacheableTask +abstract class ParseSentryManifestMetadataTask : DefaultTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val mergedManifest: RegularFileProperty + + @get:OutputFile abstract val outputFile: RegularFileProperty + + @TaskAction + fun parse() { + val metadata = ManifestMetadataParser.parse(mergedManifest.get().asFile) + val json = JsonOutput.toJson(metadata?.toSortedMap()) + outputFile.get().asFile.apply { writeText(json, Charsets.UTF_8) } + } +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ResolveSentryClassAvailabilityTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ResolveSentryClassAvailabilityTask.kt new file mode 100644 index 000000000..ae27b05fd --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/optimization/ResolveSentryClassAvailabilityTask.kt @@ -0,0 +1,34 @@ +package io.sentry.android.gradle.tasks.optimization + +import groovy.json.JsonOutput +import io.sentry.android.gradle.instrumentation.resolveClassAvailability +import org.gradle.api.DefaultTask +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 + +@CacheableTask +abstract class ResolveSentryClassAvailabilityTask : DefaultTask() { + + @get:Input abstract val moduleIds: SetProperty + + @get:OutputFile abstract val outputFile: RegularFileProperty + + @TaskAction + fun resolve() { + val modules = + moduleIds + .get() + .mapNotNull { coordinate -> + val parts = coordinate.split(':', limit = 2) + if (parts.size == 2) DefaultModuleIdentifier.newId(parts[0], parts[1]) else null + } + .toSet() + val json = JsonOutput.toJson(resolveClassAvailability(modules).toSortedMap()) + outputFile.get().asFile.apply { writeText(json, Charsets.UTF_8) } + } +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/telemetry/SentryTelemetryService.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/telemetry/SentryTelemetryService.kt index b8bd23b8f..65b42dd29 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/telemetry/SentryTelemetryService.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/telemetry/SentryTelemetryService.kt @@ -147,6 +147,14 @@ abstract class SentryTelemetryService : BuildService, BuildOperationListen } fun captureError(exception: Throwable, operation: String?) { + captureError(exception, operation, handled = false) + } + + fun captureHandledError(exception: Throwable, operation: String?) { + captureError(exception, operation, handled = true) + } + + private fun captureError(exception: Throwable, operation: String?, handled: Boolean) { val message = if (exception is SentryCliException) { "$operation failed with SentryCliException and reason ${exception.reason}" @@ -157,7 +165,7 @@ abstract class SentryTelemetryService : BuildService, BuildOperationListen val mechanism = Mechanism().also { it.type = MECHANISM_TYPE - it.isHandled = false + it.isHandled = handled } val mechanismException: Throwable = ExceptionMechanismException( @@ -165,7 +173,10 @@ abstract class SentryTelemetryService : BuildService, BuildOperationListen SentryMinimalException(message), Thread.currentThread(), ) - val event = SentryEvent(mechanismException).also { it.level = SentryLevel.FATAL } + val event = + SentryEvent(mechanismException).also { + it.level = if (handled) SentryLevel.ERROR else SentryLevel.FATAL + } scopes.captureEvent(event) } diff --git a/plugin-build/src/test/kotlin/io/sentry/android/core/SentryGeneratedBuildTimeOptions.kt b/plugin-build/src/test/kotlin/io/sentry/android/core/SentryGeneratedBuildTimeOptions.kt index 2cf62dd10..36840d9f5 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/core/SentryGeneratedBuildTimeOptions.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/core/SentryGeneratedBuildTimeOptions.kt @@ -2,6 +2,9 @@ package io.sentry.android.core object SentryGeneratedBuildTimeOptions { var availability: Map = emptyMap() + var metadata: Map? = null @JvmStatic fun getClassAvailability(): Map = availability + + @JvmStatic fun getManifestMetadata(): Map? = metadata } diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt new file mode 100644 index 000000000..88f3d7631 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt @@ -0,0 +1,89 @@ +package io.sentry.android.gradle + +import com.google.common.truth.Truth.assertThat +import java.io.File +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ManifestMetadataParserTest { + @get:Rule val temporaryFolder = TemporaryFolder() + + @Test + fun `parses sentry metadata values`() { + val manifest = + manifest( + """ + + + + + + + + """ + ) + + assertThat(ManifestMetadataParser.parse(manifest)) + .containsExactly( + "io.sentry.debug", + "true", + "io.sentry.enabled", + "false", + "io.sentry.max-breadcrumbs", + "42", + "io.sentry.hex", + "-0x2A", + "io.sentry.sample-rate", + "0.5", + "io.sentry.dsn", + "https://example.invalid/1", + ) + } + + @Test + fun `infers PackageManager value types`() { + assertThat( + listOf("true", "false", "42", "-0x2A", "0.5", "https://example.invalid/1") + .map(ManifestMetadataParser::inferType) + ) + .containsExactly(true, false, 42, -42, 0.5f, "https://example.invalid/1") + .inOrder() + } + + @Test + fun `returns null for values that PackageManager must resolve`() { + assertThat( + ManifestMetadataParser.parse( + manifest("""""") + ) + ) + .isNull() + assertThat( + ManifestMetadataParser.parse( + manifest("""""") + ) + ) + .isNull() + assertThat( + ManifestMetadataParser.parse( + manifest("""""") + ) + ) + .isNull() + } + + private fun manifest(metadata: String): File = + temporaryFolder.newFile("AndroidManifest-${temporaryFolder.root.listFiles()?.size}.xml").apply { + writeText( + """ + + + $metadata + + + """ + .trimIndent() + ) + } +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt new file mode 100644 index 000000000..d4f0ce281 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt @@ -0,0 +1,117 @@ +package io.sentry.android.gradle.instrumentation + +import com.google.common.truth.Truth.assertThat +import io.sentry.android.core.SentryGeneratedBuildTimeOptions +import org.junit.Test +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes + +class ManifestMetadataClassVisitorTest { + @Test + fun `injects generated metadata when static initializer is missing`() { + val metadata = mapOf("io.sentry.debug" to true, "io.sentry.max-breadcrumbs" to 42) + SentryGeneratedBuildTimeOptions.metadata = metadata + + assertThat(readMetadata(load(transformClass()))).containsExactlyEntriesIn(metadata) + } + + @Test + fun `injects metadata and preserves existing static initializer`() { + val metadata = mapOf("io.sentry.debug" to false) + SentryGeneratedBuildTimeOptions.metadata = metadata + + val clazz = load(transformClass(hasStaticInitializer = true)) + + assertThat(clazz.getDeclaredField("marker").getInt(null)).isEqualTo(7) + assertThat(readMetadata(clazz)).containsExactlyEntriesIn(metadata) + } + + @Test + fun `does not modify SDK versions without the metadata field`() { + val clazz = load(transformClass(hasMetadataField = false)) + + assertThat(clazz.declaredFields.map { it.name }).doesNotContain("manifestMetadata") + } + + @Test + fun `does not modify a non-static metadata field`() { + val clazz = load(transformClass(staticMetadataField = false)) + + Class.forName(clazz.name, true, clazz.classLoader) + assertThat( + java.lang.reflect.Modifier.isStatic(clazz.getDeclaredField("manifestMetadata").modifiers) + ) + .isFalse() + } + + private fun transformClass( + hasMetadataField: Boolean = true, + staticMetadataField: Boolean = true, + hasStaticInitializer: Boolean = false, + ): ByteArray { + val original = ClassWriter(0) + original.visit( + Opcodes.V1_8, + Opcodes.ACC_PUBLIC, + MANIFEST_METADATA_READER_INTERNAL_NAME, + null, + "java/lang/Object", + null, + ) + if (hasMetadataField) { + original + .visitField( + if (staticMetadataField) Opcodes.ACC_STATIC else Opcodes.ACC_PUBLIC, + "manifestMetadata", + "Ljava/util/Map;", + null, + null, + ) + .visitEnd() + } + if (hasStaticInitializer) { + original + .visitField(Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC, "marker", "I", null, null) + .visitEnd() + original.visitMethod(Opcodes.ACC_STATIC, "", "()V", null, null).apply { + visitCode() + visitIntInsn(Opcodes.BIPUSH, 7) + visitFieldInsn(Opcodes.PUTSTATIC, MANIFEST_METADATA_READER_INTERNAL_NAME, "marker", "I") + visitInsn(Opcodes.RETURN) + visitMaxs(1, 0) + visitEnd() + } + } + original.visitEnd() + + val reader = ClassReader(original.toByteArray()) + val writer = ClassWriter(reader, ClassWriter.COMPUTE_FRAMES or ClassWriter.COMPUTE_MAXS) + reader.accept(ManifestMetadataClassVisitor(Opcodes.ASM9, writer), 0) + return writer.toByteArray() + } + + private fun load(bytes: ByteArray): Class<*> = + object : ClassLoader(javaClass.classLoader) { + fun define(): Class<*> = + defineClass( + SentrySdkOptimizationClassVisitorFactory.MANIFEST_METADATA_READER_NAME, + bytes, + 0, + bytes.size, + ) + } + .define() + + @Suppress("UNCHECKED_CAST") + private fun readMetadata(clazz: Class<*>): Map = + clazz.getDeclaredField("manifestMetadata").run { + isAccessible = true + get(null) as Map + } + + private companion object { + const val MANIFEST_METADATA_READER_INTERNAL_NAME = + "io/sentry/android/core/ManifestMetadataReader" + } +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginConfigurationCacheTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginConfigurationCacheTest.kt index 4ac09066f..ea14cdc41 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginConfigurationCacheTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/integration/SentryPluginConfigurationCacheTest.kt @@ -146,9 +146,10 @@ class SentryPluginConfigurationCacheTest : runner.withArguments("--configuration-cache", "--build-cache", ":app:assembleDebug") val run0 = runner.build() - assertEquals( - TaskOutcome.SUCCESS, - run0.task(":app:generateSentryBuildTimeOptionsDebug")?.outcome, + val run0Outcome = run0.task(":app:generateSentryBuildTimeOptionsDebug")?.outcome + assertTrue( + run0Outcome == TaskOutcome.SUCCESS || run0Outcome == TaskOutcome.FROM_CACHE, + run0.output, ) assertFalse( "Reusing configuration cache." in run0.output || 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 35349a586..77f64c282 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 @@ -581,6 +581,28 @@ class SentryPluginTest : assertFalse("RuntimeClasspath' was resolved during configuration time" in build.output) } + @Test + fun `generates resolved manifest metadata`() { + configureFakeMetadataSdk("true") + + runner.appendArguments(":app:assembleRelease").build() + + assertTrue(releaseDexContains(BUILD_TIME_METADATA_KEY)) + } + + @Test + fun `falls back to PackageManager for resource metadata`() { + configureFakeMetadataSdk("@string/sentry_debug", "android:resource") + File(testProjectDir.root, "app/src/main/res/values/strings.xml").apply { + parentFile.mkdirs() + writeText("true") + } + + runner.appendArguments(":app:assembleRelease").build() + + assertFalse(releaseDexContains(BUILD_TIME_METADATA_KEY)) + } + @Test fun `register tracing instrumentation if tracingInstrumentation is enabled`() { applyTracingInstrumentation() @@ -1358,6 +1380,71 @@ class SentryPluginTest : ) } + private fun configureFakeMetadataSdk(value: String, attribute: String = "android:value") { + File(testProjectDir.root, "settings.gradle") + .appendText("\nproject(':module').name = 'sentry-android-core'") + File( + testProjectDir.root, + "module/src/main/java/io/sentry/android/core/ManifestMetadataReader.java", + ) + .apply { + parentFile.mkdirs() + writeText( + """ + package io.sentry.android.core; + + import java.util.Map; + + public final class ManifestMetadataReader { + static Map manifestMetadata; + } + """ + .trimIndent() + ) + } + appBuildFile.appendText( + """ + + dependencies { + implementation project(':sentry-android-core') + } + + android { + buildTypes.release.minifyEnabled = false + } + """ + .trimIndent() + ) + File(testProjectDir.root, "app/src/main/AndroidManifest.xml").apply { + parentFile.mkdirs() + writeText( + """ + + + + + + """ + .trimIndent() + ) + } + } + + private fun releaseDexContains(value: String): Boolean { + val apk = File(testProjectDir.root, "app/build/outputs/apk/release/app-release-unsigned.apk") + return java.util.zip.ZipFile(apk).use { zip -> + zip + .entries() + .asSequence() + .filter { it.name.matches(Regex("classes\\d*\\.dex")) } + .any { entry -> + zip.getInputStream(entry).use { + it.readBytes().toString(Charsets.ISO_8859_1).contains(value) + } + } + } + } + private fun buildDatabaseInstrumentation(vararg dependencies: String): BuildResult { applyTracingInstrumentation( features = setOf(InstrumentationFeature.DATABASE), @@ -1385,6 +1472,7 @@ class SentryPluginTest : } companion object { + private const val BUILD_TIME_METADATA_KEY = "io.sentry.test-build-time-injection" 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" private val SENTRY_ANDROID_SQLITE_DRIVER = diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt deleted file mode 100644 index f68b76ca6..000000000 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/GenerateSentryBuildTimeOptionsTaskTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package io.sentry.android.gradle.tasks - -import com.google.common.truth.Truth.assertThat -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 GenerateSentryBuildTimeOptionsTaskTest { - - @get:Rule val tempDir = TemporaryFolder() - - @Test - fun `generates class availability source`() { - val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("project")).build() - val outputDir = tempDir.newFolder("generated") - val task = - project.tasks.register("generateOptions", GenerateSentryBuildTimeOptionsTask::class.java) { - it.moduleIds.set(setOf("com.jakewharton.timber:timber", "androidx.core:core")) - it.output.set(outputDir) - } - - task.get().generate() - - val source = - outputDir.resolve("io/sentry/android/core/SentryGeneratedBuildTimeOptions.java").readText() - assertThat(source).contains("availability.put(\"timber.log.Timber\", true);") - assertThat(source).contains("availability.put(\"androidx.core.view.ScrollingView\", true);") - assertThat(source).contains("availability.put(\"androidx.lifecycle.Lifecycle\", false);") - } - - @Test - fun `register does not resolve runtime classpath`() { - val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("java-project")).build() - project.plugins.apply("java") - val runtimeClasspath = project.configurations.getByName("runtimeClasspath") - - GenerateSentryBuildTimeOptionsTask.register(project, "runtimeClasspath", "Test")?.get() - - assertThat(runtimeClasspath.state).isEqualTo(Configuration.State.UNRESOLVED) - } - - @Test - fun `register includes project dependencies`() { - val root = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("root")).build() - val replayDir = root.file("sentry-android-replay").apply { mkdir() } - val replay = - ProjectBuilder.builder() - .withName("sentry-android-replay") - .withProjectDir(replayDir) - .withParent(root) - .build() - replay.group = "io.sentry" - replay.plugins.apply("java") - val appDir = root.file("app").apply { mkdir() } - val app = - ProjectBuilder.builder().withName("app").withProjectDir(appDir).withParent(root).build() - app.plugins.apply("java") - app.dependencies.add( - "implementation", - app.dependencies.project(mapOf("path" to ":sentry-android-replay")), - ) - - val task = GenerateSentryBuildTimeOptionsTask.register(app, "runtimeClasspath", "Test")!!.get() - - assertThat(task.moduleIds.get()).contains("io.sentry:sentry-android-replay") - } -} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTaskTest.kt new file mode 100644 index 000000000..cf9876b1c --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/optimization/GenerateSentryBuildTimeOptionsTaskTest.kt @@ -0,0 +1,166 @@ +package io.sentry.android.gradle.tasks.optimization + +import com.google.common.truth.Truth.assertThat +import io.sentry.android.gradle.telemetry.SentryTelemetryService +import org.gradle.api.artifacts.Configuration +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class GenerateSentryBuildTimeOptionsTaskTest { + + @get:Rule val tempDir = TemporaryFolder() + + @Test + fun `generates build time options source`() { + val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("project")).build() + val outputDir = tempDir.newFolder("generated") + val availabilityFile = tempDir.newFile("class-availability.json") + val metadataFile = tempDir.newFile("manifest-metadata.json") + val manifest = + tempDir.newFile("AndroidManifest.xml").apply { + writeText( + """ + + + + + + + """ + .trimIndent() + ) + } + val availabilityTask = + project.tasks.register( + "resolveAvailability", + ResolveSentryClassAvailabilityTask::class.java, + ) { + it.moduleIds.set(setOf("com.jakewharton.timber:timber", "androidx.core:core")) + it.outputFile.set(availabilityFile) + } + val metadataTask = + project.tasks.register("parseMetadata", ParseSentryManifestMetadataTask::class.java) { + it.mergedManifest.set(manifest) + it.outputFile.set(metadataFile) + } + val generateTask = + project.tasks.register("generateOptions", GenerateSentryBuildTimeOptionsTask::class.java) { + it.classAvailabilityFile.set(availabilityFile) + it.manifestMetadataFile.set(metadataFile) + it.output.set(outputDir) + } + + availabilityTask.get().resolve() + metadataTask.get().parse() + generateTask.get().generate() + + val source = + outputDir.resolve("io/sentry/android/core/SentryGeneratedBuildTimeOptions.java").readText() + assertThat(source).contains("availability.put(\"timber.log.Timber\", true);") + assertThat(source).contains("availability.put(\"androidx.core.view.ScrollingView\", true);") + assertThat(source).contains("availability.put(\"androidx.lifecycle.Lifecycle\", false);") + assertThat(source).contains("metadata.put(\"io.sentry.debug\", true);") + assertThat(source).contains("metadata.put(\"io.sentry.dsn\", \"quoted&\\\"value\");") + } + + @Test + fun `preserves manifest metadata fallback`() { + assertThat(generateSource("null")).contains(" return null;") + } + + @Test + fun `falls back when manifest metadata type cannot be inferred`() { + val telemetry = mock() + + assertThat(generateSource("""{"io.sentry.max-breadcrumbs":"0xFFFFFFFFFFFFFFFF"}""", telemetry)) + .contains(" return null;") + verify(telemetry).captureHandledError(any(), eq("manifest metadata type inference")) + } + + @Test + fun `preserves authoritative empty manifest metadata`() { + val source = generateSource("{}") + + assertThat(source).contains("Map metadata = new HashMap<>();") + assertThat(source).doesNotContain("metadata.put(") + } + + @Test + fun `register does not resolve runtime classpath`() { + val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("java-project")).build() + project.plugins.apply("java") + val runtimeClasspath = project.configurations.getByName("runtimeClasspath") + + GenerateSentryBuildTimeOptionsTask.register( + project, + "runtimeClasspath", + "Test", + project.layout.file(project.provider { project.file("AndroidManifest.xml") }), + ) + ?.get() + + assertThat(runtimeClasspath.state).isEqualTo(Configuration.State.UNRESOLVED) + } + + @Test + fun `register includes project dependencies`() { + val root = ProjectBuilder.builder().withProjectDir(tempDir.newFolder("root")).build() + val replayDir = root.file("sentry-android-replay").apply { mkdir() } + val replay = + ProjectBuilder.builder() + .withName("sentry-android-replay") + .withProjectDir(replayDir) + .withParent(root) + .build() + replay.group = "io.sentry" + replay.plugins.apply("java") + val appDir = root.file("app").apply { mkdir() } + val app = + ProjectBuilder.builder().withName("app").withProjectDir(appDir).withParent(root).build() + app.plugins.apply("java") + app.dependencies.add( + "implementation", + app.dependencies.project(mapOf("path" to ":sentry-android-replay")), + ) + + GenerateSentryBuildTimeOptionsTask.register( + app, + "runtimeClasspath", + "Test", + app.layout.file(app.provider { app.file("AndroidManifest.xml") }), + )!! + .get() + val task = + app.tasks + .named("resolveSentryClassAvailabilityTest", ResolveSentryClassAvailabilityTask::class.java) + .get() + + assertThat(task.moduleIds.get()).contains("io.sentry:sentry-android-replay") + } + + private fun generateSource(metadata: String, telemetry: SentryTelemetryService? = null): String { + val project = ProjectBuilder.builder().withProjectDir(tempDir.newFolder()).build() + val availabilityFile = tempDir.newFile().apply { writeText("{}") } + val metadataFile = tempDir.newFile().apply { writeText(metadata) } + val outputDir = tempDir.newFolder() + val task = + project.tasks.register("generateOptions", GenerateSentryBuildTimeOptionsTask::class.java) { + it.classAvailabilityFile.set(availabilityFile) + it.manifestMetadataFile.set(metadataFile) + it.output.set(outputDir) + telemetry?.let { service -> it.sentryTelemetryService.set(service) } + } + + task.get().generate() + + return outputDir + .resolve("io/sentry/android/core/SentryGeneratedBuildTimeOptions.java") + .readText() + } +}