diff --git a/CHANGELOG.md b/CHANGELOG.md index 949e9dd6c..05691839b 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, 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 - Bump ComposablePreviewScanner from v0.9.1 to v0.9.2 ([#1381](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1381)) 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..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 @@ -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,33 @@ fun ApplicationAndroidComponentsExtension.configure( ) .toTransform(SingleArtifact.MERGED_MANIFEST) } + + if (runtimeOptimizationsEnabled) { + val runtimeModulesService = checkNotNull(modulesService) + variant.instrumentation.transformClassesWith( + SentrySdkOptimizationClassVisitorFactory::class.java, + InstrumentationScope.ALL, + ) { params -> + params.classAvailability.setDisallowChanges( + checkNotNull(modules).map(::resolveClassAvailability).orElse(emptyMap()) + ) + params.buildTimeMetadataEnabled.setDisallowChanges( + checkNotNull(modules) + .map { runtimeModulesService.get().supportsBuildTimeMetadata() } + .orElse(false) + ) + params.buildTimeMetadataCacheKey.setDisallowChanges( + params.buildTimeMetadataEnabled.map { + if (it) runtimeModulesService.get().buildTimeMetadataCacheKey else "" + } + ) + 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..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 @@ -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,55 @@ abstract class SentrySdkOptimizationClassVisitorFactory : interface SdkOptimizationParameters : InstrumentationParameters { @get:Input val classAvailability: MapProperty + + @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 } + 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..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 @@ -129,6 +132,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..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 @@ -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", "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() @@ -1346,6 +1369,79 @@ class SentryPluginTest : ) } + private fun configureFakeMetadataSdk(value: String, attribute: String = "android:value") { + 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 =