From 4c47b46155e1e7ddf5857a154852ccc9d6131bc5 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 13 Aug 2026 12:47:22 +0200 Subject: [PATCH 1/6] perf(instrumentation): Inject manifest metadata at build time Parse resolved Sentry manifest metadata and inject it into compatible Android SDK versions so startup avoids PackageManager and Bundle unparceling. Fall back to runtime lookup when values cannot be resolved safely. Refs JAVA-531 Co-Authored-By: Codex --- .../android/gradle/AndroidComponentsConfig.kt | 35 +++-- .../android/gradle/ManifestMetadataParser.kt | 72 +++++++++ .../ManifestMetadataClassVisitor.kt | 142 ++++++++++++++++++ ...entrySdkOptimizationClassVisitorFactory.kt | 47 +++++- .../gradle/services/SentryModulesService.kt | 6 + .../io/sentry/android/gradle/util/Versions.kt | 1 + .../gradle/ManifestMetadataParserTest.kt | 83 ++++++++++ .../ManifestMetadataClassVisitorTest.kt | 105 +++++++++++++ .../gradle/integration/SentryPluginTest.kt | 97 ++++++++++++ 9 files changed, 566 insertions(+), 22 deletions(-) create mode 100644 plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt create mode 100644 plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt create mode 100644 plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt create mode 100644 plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt 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 b4af24b0c..0e209ece7 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 @@ -205,20 +205,6 @@ fun ApplicationAndroidComponentsExtension.configure( 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 - ) - } - if (tracingInstrumentationEnabled) { val tracingModulesService = checkNotNull(modulesService) @@ -262,6 +248,27 @@ fun ApplicationAndroidComponentsExtension.configure( ) .toTransform(SingleArtifact.MERGED_MANIFEST) } + + if (runtimeOptimizationsEnabled) { + variant.instrumentation.transformClassesWith( + SentrySdkOptimizationClassVisitorFactory::class.java, + InstrumentationScope.ALL, + ) { params -> + params.classAvailability.setDisallowChanges( + checkNotNull(modules).map(::resolveClassAvailability).orElse(emptyMap()) + ) + params.buildTimeMetadataEnabled.setDisallowChanges( + checkNotNull(modules) + .map { checkNotNull(modulesService).get().supportsBuildTimeMetadata() } + .orElse(false) + ) + params.mergedManifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)) + } + variant.instrumentation.setAsmFramesComputationMode( + FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS + ) + } + val sizeAnalysisEnabled = extension.sizeAnalysis.enabled.get() == true val distributionEnabled = extension.distribution.enabled.get() == true if (sizeAnalysisEnabled || distributionEnabled) { 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..7c153da6d --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/ManifestMetadataParser.kt @@ -0,0 +1,72 @@ +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(TAG_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 != TAG_META_DATA) continue + + val name = element.getAttribute(ATTR_NAME) + if (!name.startsWith(SENTRY_PREFIX)) continue + val value = element.getAttribute(ATTR_VALUE) + if ( + element.hasAttribute(ATTR_RESOURCE) || + !element.hasAttribute(ATTR_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] = inferType(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() ?: 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 const val TAG_APPLICATION = "application" + private const val TAG_META_DATA = "meta-data" + private const val ATTR_NAME = "android:name" + private const val ATTR_VALUE = "android:value" + private const val ATTR_RESOURCE = "android:resource" + private const val SENTRY_PREFIX = "io.sentry." + 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..35682e818 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitor.kt @@ -0,0 +1,142 @@ +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 fully resolved build-time manifest metadata into `ManifestMetadataReader`. */ +internal class ManifestMetadataClassVisitor( + apiVersion: Int, + nextClassVisitor: ClassVisitor, + private val metadata: Map, +) : 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) 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.visitTypeInsn(Opcodes.NEW, HASH_MAP_NAME) + visitor.visitInsn(Opcodes.DUP) + visitor.visitMethodInsn(Opcodes.INVOKESPECIAL, HASH_MAP_NAME, "", "()V", false) + visitor.visitFieldInsn(Opcodes.PUTSTATIC, READER_INTERNAL_NAME, METADATA_FIELD, MAP_DESCRIPTOR) + + metadata.forEach { (key, value) -> + visitor.visitFieldInsn( + Opcodes.GETSTATIC, + READER_INTERNAL_NAME, + METADATA_FIELD, + MAP_DESCRIPTOR, + ) + visitor.visitLdcInsn(key) + visitor.emitValue(value) + visitor.visitMethodInsn( + Opcodes.INVOKEINTERFACE, + "java/util/Map", + "put", + "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", + true, + ) + visitor.visitInsn(Opcodes.POP) + } + } + + private fun MethodVisitor.emitValue(value: Any) { + when (value) { + is Boolean -> { + visitInsn(if (value) Opcodes.ICONST_1 else Opcodes.ICONST_0) + visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/lang/Boolean", + "valueOf", + "(Z)Ljava/lang/Boolean;", + false, + ) + } + is Int -> { + visitLdcInsn(value) + visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/lang/Integer", + "valueOf", + "(I)Ljava/lang/Integer;", + false, + ) + } + is Float -> { + visitLdcInsn(value) + visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/lang/Float", + "valueOf", + "(F)Ljava/lang/Float;", + false, + ) + } + is String -> visitLdcInsn(value) + } + } + + private companion object { + const val READER_INTERNAL_NAME = "io/sentry/android/core/ManifestMetadataReader" + const val METADATA_FIELD = "buildTimeMetadata" + const val MAP_DESCRIPTOR = "Ljava/util/Map;" + const val HASH_MAP_NAME = "java/util/HashMap" + 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 5f5cb497c..c42295afa 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 @@ -4,11 +4,17 @@ import com.android.build.api.instrumentation.AsmClassVisitorFactory 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.ManifestMetadataParser import io.sentry.android.gradle.util.SentryModules import org.gradle.api.artifacts.ModuleIdentifier +import org.gradle.api.file.RegularFileProperty import org.gradle.api.internal.artifacts.DefaultModuleIdentifier import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property 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 : @@ -16,26 +22,51 @@ abstract class SentrySdkOptimizationClassVisitorFactory : interface SdkOptimizationParameters : InstrumentationParameters { @get:Input val classAvailability: MapProperty + + @get:Input val buildTimeMetadataEnabled: Property + + @get:InputFile @get:PathSensitive(PathSensitivity.NONE) val mergedManifest: RegularFileProperty } + private fun buildTimeMetadata(): Map? = + if (parameters.get().buildTimeMetadataEnabled.get()) { + ManifestMetadataParser.parse(parameters.get().mergedManifest.asFile.get()) + } else { + null + } + override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor, - ): ClassVisitor { - return LoadClassClassVisitor( - instrumentationContext.apiVersion.get(), - nextClassVisitor, - parameters.get().classAvailability.get(), - ) - } + ): ClassVisitor = + when (classContext.currentClassData.className) { + LOAD_CLASS_NAME -> + LoadClassClassVisitor( + instrumentationContext.apiVersion.get(), + nextClassVisitor, + parameters.get().classAvailability.get(), + ) + MANIFEST_METADATA_READER_NAME -> + ManifestMetadataClassVisitor( + instrumentationContext.apiVersion.get(), + nextClassVisitor, + checkNotNull(buildTimeMetadata()), + ) + else -> nextClassVisitor + } // Empty availability means the runtime classpath is unknown. Skip this transformation so // LoadClass falls back to reflection. override fun isInstrumentable(classData: ClassData): Boolean = - classData.className == LOAD_CLASS_NAME && parameters.get().classAvailability.get().isNotEmpty() + when (classData.className) { + LOAD_CLASS_NAME -> parameters.get().classAvailability.get().isNotEmpty() + MANIFEST_METADATA_READER_NAME -> buildTimeMetadata() != null + else -> false + } 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/services/SentryModulesService.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt index b7d5aacf6..78305a047 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt @@ -129,6 +129,12 @@ abstract class SentryModulesService : sentryModules.isAtLeast(SentryModules.SENTRY_ANDROID_CORE, SentryVersions.VERSION_APP_START) && parameters.appStartEnabled.get() + fun supportsBuildTimeMetadata(): Boolean = + sentryModules.isAtLeast( + SentryModules.SENTRY_ANDROID_CORE, + SentryVersions.VERSION_BUILD_TIME_METADATA, + ) + private fun Map.isAtLeast( module: ModuleIdentifier, minVersion: SemVer, diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt index 19f41a239..1dbdb7582 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt @@ -38,6 +38,7 @@ internal object SentryVersions { internal val VERSION_APP_START = SemVer(7, 1, 0) internal val VERSION_SQLITE = SemVer(6, 21, 0) internal val VERSION_SQLITE_DRIVER = SemVer(8, 45, 0) + internal val VERSION_BUILD_TIME_METADATA = SemVer(8, 54, 0) internal val VERSION_ANDROID_OKHTTP_LISTENER = SemVer(6, 20, 0) internal val VERSION_OKHTTP = SemVer(7, 0, 0) } 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..cda985e36 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/ManifestMetadataParserTest.kt @@ -0,0 +1,83 @@ +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 with PackageManager value types`() { + val manifest = + manifest( + """ + + + + + + + + """ + ) + + assertThat(ManifestMetadataParser.parse(manifest)) + .containsExactly( + "io.sentry.debug", + true, + "io.sentry.enabled", + false, + "io.sentry.max-breadcrumbs", + 42, + "io.sentry.hex", + -42, + "io.sentry.sample-rate", + 0.5f, + "io.sentry.dsn", + "https://example.invalid/1", + ) + } + + @Test + fun `returns null for resource references`() { + assertThat( + ManifestMetadataParser.parse( + manifest("""""") + ) + ) + .isNull() + assertThat( + ManifestMetadataParser.parse( + manifest("""""") + ) + ) + .isNull() + } + + @Test + fun `returns null for unresolved placeholders`() { + 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..2b27854c0 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/ManifestMetadataClassVisitorTest.kt @@ -0,0 +1,105 @@ +package io.sentry.android.gradle.instrumentation + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes + +class ManifestMetadataClassVisitorTest { + @Test + fun `injects typed metadata when static initializer is missing`() { + val metadata = + mapOf( + "io.sentry.debug" to true, + "io.sentry.max-breadcrumbs" to 42, + "io.sentry.sample-rate" to 0.5f, + "io.sentry.dsn" to "https://example.invalid/1", + ) + + val clazz = load(transformClass(metadata = metadata)) + + assertThat(readMetadata(clazz)).containsExactlyEntriesIn(metadata) + } + + @Test + fun `injects metadata and preserves existing static initializer`() { + val metadata = mapOf("io.sentry.debug" to false) + + val clazz = load(transformClass(hasStaticInitializer = true, metadata = metadata)) + + 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("buildTimeMetadata") + } + + private fun transformClass( + hasMetadataField: Boolean = true, + hasStaticInitializer: Boolean = false, + metadata: Map = emptyMap(), + ): ByteArray { + val original = ClassWriter(0) + original.visit( + Opcodes.V1_8, + Opcodes.ACC_PUBLIC, + READER_INTERNAL_NAME, + null, + "java/lang/Object", + null, + ) + if (hasMetadataField) { + original + .visitField(Opcodes.ACC_STATIC, "buildTimeMetadata", "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, 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, metadata), 0) + return writer.toByteArray() + } + + private fun load(bytes: ByteArray): Class<*> = + // Loading the transformed bytes in an isolated class loader executes their generated . + 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("buildTimeMetadata").run { + isAccessible = true + get(null) as Map + } + + private companion object { + const val READER_INTERNAL_NAME = "io/sentry/android/core/ManifestMetadataReader" + } +} 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..0ce307899 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 @@ -569,6 +569,29 @@ class SentryPluginTest : assertTrue("Detected Sentry modules" in build.output) } + @Test + fun `injects resolved manifest metadata into the SDK`() { + configureFakeMetadataSdk("true") + + runner.appendArguments(":app:assembleRelease").build() + + assertTrue(releaseDexContains(BUILD_TIME_METADATA_KEY)) + assertTrue(releaseDexContains("io.sentry.gradle-plugin-integrations")) + } + + @Test + fun `does not inject manifest metadata with resource references`() { + configureFakeMetadataSdk("@string/sentry_debug") + 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() @@ -1346,6 +1369,79 @@ class SentryPluginTest : ) } + private fun configureFakeMetadataSdk(value: String) { + File(testProjectDir.root, "settings.gradle") + .appendText("\nproject(':module').name = 'sentry-android-core'") + moduleBuildFile.appendText( + """ + + group = 'io.sentry' + version = '${SentryVersions.VERSION_BUILD_TIME_METADATA}' + """ + .trimIndent() + ) + 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 buildTimeMetadata; + } + """ + .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), @@ -1373,6 +1469,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 = From 0ff9af070090ce0a3057c4ac46970922e1d68b59 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 13 Aug 2026 13:40:04 +0200 Subject: [PATCH 2/6] docs(changelog): Add build-time metadata optimization Refs JAVA-531 Co-Authored-By: Codex --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 949e9dd6c..77d773712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - 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()` +### Performance + +- Resolve Sentry Android manifest metadata at build time to avoid `PackageManager` and `Bundle` parsing during SDK initialization ([#1401](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1401)) + ### Dependencies - Bump ComposablePreviewScanner from v0.9.1 to v0.9.2 ([#1381](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1381)) From 6a94d31ae8fa2f5f400f9254055fa7345a68c22f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 13 Aug 2026 13:45:26 +0200 Subject: [PATCH 3/6] docs(changelog): Add benchmark result Refs JAVA-531 Co-Authored-By: Codex --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d773712..05691839b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Performance -- Resolve Sentry Android manifest metadata at build time to avoid `PackageManager` and `Bundle` parsing during SDK initialization ([#1401](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1401)) +- Resolve Sentry Android manifest metadata at build time to avoid `PackageManager` and `Bundle` parsing, reducing median SDK initialization time by 6.5% in a cold-start benchmark ([#1401](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1401)) ### Dependencies From 5b0ebf9a4e8df4d60150c0ecbecaca245c143b33 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 13 Aug 2026 15:19:51 +0200 Subject: [PATCH 4/6] test: Keep manifest resource fixture unresolved Use android:resource so newer AGP versions cannot resolve the fixture before the merged manifest reaches the parser. Refs JAVA-531 Co-Authored-By: OpenAI Codex --- .../sentry/android/gradle/integration/SentryPluginTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 0ce307899..be05618d8 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,7 +581,7 @@ class SentryPluginTest : @Test fun `does not inject manifest metadata with resource references`() { - configureFakeMetadataSdk("@string/sentry_debug") + configureFakeMetadataSdk("@string/sentry_debug", "android:resource") File(testProjectDir.root, "app/src/main/res/values/strings.xml").apply { parentFile.mkdirs() writeText("true") @@ -1369,7 +1369,7 @@ class SentryPluginTest : ) } - private fun configureFakeMetadataSdk(value: String) { + private fun configureFakeMetadataSdk(value: String, attribute: String = "android:value") { File(testProjectDir.root, "settings.gradle") .appendText("\nproject(':module').name = 'sentry-android-core'") moduleBuildFile.appendText( @@ -1418,7 +1418,7 @@ class SentryPluginTest : """ - + """ From 1a0e822080c18ceb2bef2130cb4dc247175a864e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 13 Aug 2026 18:08:58 +0200 Subject: [PATCH 5/6] fix(instrumentation): Isolate manifest metadata transforms Prevent AGP dependency instrumentation from reusing manifest metadata injected for another app while keeping older SDK transforms cacheable. Refs JAVA-531 Co-Authored-By: OpenAI Codex --- .../io/sentry/android/gradle/AndroidComponentsConfig.kt | 5 +++++ .../SentrySdkOptimizationClassVisitorFactory.kt | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt index 0e209ece7..8c82192c9 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 @@ -262,6 +262,11 @@ fun ApplicationAndroidComponentsExtension.configure( .map { checkNotNull(modulesService).get().supportsBuildTimeMetadata() } .orElse(false) ) + params.buildTimeMetadataCacheKey.setDisallowChanges( + params.buildTimeMetadataEnabled.map { + if (it) java.util.UUID.randomUUID().toString() else "" + } + ) params.mergedManifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)) } variant.instrumentation.setAsmFramesComputationMode( diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SentrySdkOptimizationClassVisitorFactory.kt index c42295afa..efc59165e 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 @@ -25,6 +25,10 @@ abstract class SentrySdkOptimizationClassVisitorFactory : @get:Input val buildTimeMetadataEnabled: Property + // The merged manifest cannot be read before dependency transforms are isolated, so use a + // per-build key to prevent AGP from reusing metadata injected for another app. + @get:Input val buildTimeMetadataCacheKey: Property + @get:InputFile @get:PathSensitive(PathSensitivity.NONE) val mergedManifest: RegularFileProperty } From 0d32407b3e5265224a055699d185ceaf57563843 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 13 Aug 2026 18:46:07 +0200 Subject: [PATCH 6/6] fix(instrumentation): Stabilize metadata cache key per build Generate one manifest metadata cache key from the per-build modules service so repeated Gradle input queries stay stable and configuration-cache restores receive a fresh key. Refs JAVA-531 Co-Authored-By: OpenAI Codex --- .../io/sentry/android/gradle/AndroidComponentsConfig.kt | 5 +++-- .../sentry/android/gradle/services/SentryModulesService.kt | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt index 8c82192c9..3705d3e45 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 @@ -250,6 +250,7 @@ fun ApplicationAndroidComponentsExtension.configure( } if (runtimeOptimizationsEnabled) { + val runtimeModulesService = checkNotNull(modulesService) variant.instrumentation.transformClassesWith( SentrySdkOptimizationClassVisitorFactory::class.java, InstrumentationScope.ALL, @@ -259,12 +260,12 @@ fun ApplicationAndroidComponentsExtension.configure( ) params.buildTimeMetadataEnabled.setDisallowChanges( checkNotNull(modules) - .map { checkNotNull(modulesService).get().supportsBuildTimeMetadata() } + .map { runtimeModulesService.get().supportsBuildTimeMetadata() } .orElse(false) ) params.buildTimeMetadataCacheKey.setDisallowChanges( params.buildTimeMetadataEnabled.map { - if (it) java.util.UUID.randomUUID().toString() else "" + if (it) runtimeModulesService.get().buildTimeMetadataCacheKey else "" } ) params.mergedManifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt index 78305a047..f103899c2 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/services/SentryModulesService.kt @@ -8,6 +8,7 @@ import io.sentry.android.gradle.util.SemVer import io.sentry.android.gradle.util.SentryModules import io.sentry.android.gradle.util.SentryVersions import io.sentry.android.gradle.util.getBuildServiceName +import java.util.UUID import org.gradle.api.Project import org.gradle.api.artifacts.ModuleIdentifier import org.gradle.api.provider.Property @@ -22,6 +23,8 @@ import org.gradle.tooling.events.OperationCompletionListener abstract class SentryModulesService : BuildService, OperationCompletionListener { + val buildTimeMetadataCacheKey: String = UUID.randomUUID().toString() + @get:Synchronized @set:Synchronized var sentryModules: Map = emptyMap() @get:Synchronized