diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b7caf8b..162d973a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,21 @@ ## Unreleased +### Features + +- Auto-instrument SQLiteDriver for Room users ([#1285](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1285)) + - Gated on `sentry-android-sqlite` >= 8.45.0 and the existing `tracingInstrumentation` `DATABASE` feature (enabled by default) + - For users of the `androidx.sqlite.driver.SupportSQLiteDriver` bridge, auto-instrumentation wraps only the `SupportSQLiteOpenHelper` consumed by the bridge and not the bridge itself (avoids duplicate spans) + ### Security - Pin the plugin's build dependencies with Gradle dependency locking and SHA-256 dependency verification ([#1256](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1256)) ### Dependencies +- Bump Android SDK from v8.44.1 to v8.45.0 ([#1285](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1285)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#8450) + - [diff](https://github.com/getsentry/sentry-java/compare/8.44.1...8.45.0) - Bump Android SDK from v8.44.0 to v8.44.1 ([#1305](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1305)) - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#8441) - [diff](https://github.com/getsentry/sentry-java/compare/8.44.0...8.44.1) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 601896796..2b32ba67d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ agp = "8.10.1" asm = "9.4" # // compatibility matrix -> https://developer.android.com/reference/tools/gradle-api/7.1/com/android/build/api/instrumentation/InstrumentationContext#apiversion ktfmt = "0.51" sqlite = "2.1.0" -sentry = "8.44.1" +sentry = "8.45.0" # Pinned to the last release that shipped sentry-android-okhttp; the module was removed # in 8.0.0, so there is no newer version to update to. Used only to verify the legacy # okhttp instrumentation path in tests. @@ -60,8 +60,11 @@ sentryAndroidOkhttp = { group = "io.sentry", name = "sentry-android-okhttp", ver sentrySpringBootJakarta = { group = "io.sentry", name = "sentry-spring-boot-starter-jakarta", version.ref = "sentry" } # test -mockitoKotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version = "5.4.0" } arscLib = { group = "io.github.reandroid", name = "ARSCLib", version = "1.1.4" } +mockitoKotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version = "5.4.0" } +# Room & Room 3 runtime versions must match RoomDatabase$Builder bytecode fixtures (see SQLiteDriverBytecodeTestUtil) +roomRuntimeAndroid = { group = "androidx.room", name = "room-runtime-android", version = "2.7.0" } +room3RuntimeAndroid = { group = "androidx.room3", name = "room3-runtime-android", version = "3.0.0-alpha06" } zip4j = { group = "net.lingala.zip4j", name = "zip4j", version = "2.11.5" } # samples diff --git a/plugin-build/build.gradle.kts b/plugin-build/build.gradle.kts index 043e2a39c..d211558b7 100644 --- a/plugin-build/build.gradle.kts +++ b/plugin-build/build.gradle.kts @@ -55,12 +55,16 @@ dependencies { testImplementation(libs.asmCommons) // we need these dependencies for tests, because the bytecode verifier also analyzes superclasses + testImplementationAar(libs.roomRuntimeAndroid) + testImplementationAar(libs.room3RuntimeAndroid) + testImplementation(libs.sample.coroutines.core) + testImplementationAar(libs.sentryAndroid) testImplementationAar(libs.sqlite) testImplementationAar(libs.sqliteFramework) - testRuntimeOnly(files(androidSdkPath)) - testImplementationAar(libs.sentryAndroid) - testImplementation(libs.sentryOkhttp) testImplementationAar(libs.sentryAndroidOkhttp) + testImplementation(libs.sentryOkhttp) + + testRuntimeOnly(files(androidSdkPath)) // Needed to read contents from APK/Source Bundles testImplementation(libs.arscLib) diff --git a/plugin-build/gradle.lockfile b/plugin-build/gradle.lockfile index 0e8af9468..ae2dcb6b2 100644 --- a/plugin-build/gradle.lockfile +++ b/plugin-build/gradle.lockfile @@ -3,6 +3,8 @@ # This file is expected to be part of source control. androidx.databinding:databinding-common:8.10.1=testRuntimeClasspath androidx.databinding:databinding-compiler-common:8.10.1=testRuntimeClasspath +androidx.room3:room3-runtime-android:3.0.0-alpha06=testImplementationAar +androidx.room:room-runtime-android:2.7.0=testImplementationAar androidx.sqlite:sqlite-framework:2.1.0=testImplementationAar androidx.sqlite:sqlite:2.1.0=testImplementationAar com.android.databinding:baseLibrary:8.10.1=testRuntimeClasspath @@ -108,9 +110,9 @@ io.netty:netty-transport-native-unix-common:4.1.110.Final=testRuntimeClasspath io.netty:netty-transport:4.1.110.Final=testRuntimeClasspath io.perfmark:perfmark-api:0.27.0=testRuntimeClasspath io.sentry:sentry-android-okhttp:7.22.6=testImplementationAar -io.sentry:sentry-android:8.44.1=testImplementationAar -io.sentry:sentry-okhttp:8.44.1=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath -io.sentry:sentry:8.44.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +io.sentry:sentry-android:8.45.0=testImplementationAar +io.sentry:sentry-okhttp:8.45.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +io.sentry:sentry:8.45.0=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath it.unimi.dsi:fastutil-core:8.5.12=dokkaGfmPlugin,dokkaHtmlPlugin,dokkaJavadocPlugin,dokkaJekyllPlugin jakarta.activation:jakarta.activation-api:1.2.1=dokkaGfmRuntime,dokkaHtmlRuntime,dokkaJavadocRuntime,dokkaJekyllRuntime,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=dokkaGfmRuntime,dokkaHtmlRuntime,dokkaJavadocRuntime,dokkaJekyllRuntime,testRuntimeClasspath @@ -176,7 +178,7 @@ org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.1.21=kotlinBuil org.jetbrains.kotlin:kotlin-scripting-jvm:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest org.jetbrains.kotlin:kotlin-stdlib-common:1.9.22=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime org.jetbrains.kotlin:kotlin-stdlib-common:2.0.0=compileClasspath,compileOnlyDependenciesMetadata -org.jetbrains.kotlin:kotlin-stdlib-common:2.1.21=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.20=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=dokkaGfmRuntime,dokkaHtmlRuntime,dokkaJavadocRuntime,dokkaJekyllRuntime org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0=dokkaGfmPlugin,dokkaHtmlPlugin,dokkaJavadocPlugin,dokkaJekyllPlugin org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0=testRuntimeClasspath @@ -185,18 +187,23 @@ org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0=dokkaGfmPlugin,dokkaHtmlPlugin,dok org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.0=testRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib:1.9.22=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime org.jetbrains.kotlin:kotlin-stdlib:2.0.0=compileClasspath,compileOnlyDependenciesMetadata -org.jetbrains.kotlin:kotlin-stdlib:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.20=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlin:kotlin-test-junit:2.1.21=testCompileClasspath,testRuntimeClasspath org.jetbrains.kotlin:kotlin-test:2.1.21=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlin:kotlin-tooling-core:2.1.21=compileClasspath,compileOnlyDependenciesMetadata org.jetbrains.kotlin:kotlin-util-io:2.1.21=compileClasspath,compileOnlyDependenciesMetadata +org.jetbrains.kotlinx:atomicfu:0.23.1=testImplementationDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=testCompileClasspath,testRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=testCompileClasspath,testRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime org.jetbrains.kotlinx:kotlinx-html-jvm:0.9.1=dokkaGfmPlugin,dokkaHtmlPlugin,dokkaJavadocPlugin,dokkaJekyllPlugin -org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,testCompileClasspath -org.jetbrains:annotations:23.0.0=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath +org.jetbrains:annotations:23.0.0=dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime,testCompileClasspath org.jetbrains:annotations:24.0.0=testRuntimeClasspath org.jetbrains:annotations:24.0.1=compileClasspath,compileOnlyDependenciesMetadata org.jetbrains:markdown-jvm:0.5.2=dokkaGfmPlugin,dokkaHtmlPlugin,dokkaJavadocPlugin,dokkaJekyllPlugin diff --git a/plugin-build/gradle/verification-metadata.xml b/plugin-build/gradle/verification-metadata.xml index 7ec1bdbc0..736c85858 100644 --- a/plugin-build/gradle/verification-metadata.xml +++ b/plugin-build/gradle/verification-metadata.xml @@ -21,6 +21,22 @@ + + + + + + + + + + + + + + + + @@ -1454,6 +1470,14 @@ + + + + + + + + @@ -1486,6 +1510,14 @@ + + + + + + + + @@ -1526,6 +1558,14 @@ + + + + + + + + @@ -2919,6 +2959,17 @@ + + + + + + + + + + + @@ -2960,6 +3011,11 @@ + + + + + @@ -3202,6 +3258,19 @@ + + + + + + + + + + + + + @@ -3217,6 +3286,14 @@ + + + + + + + + @@ -3227,6 +3304,14 @@ + + + + + + + + diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/TracingInstrumentationExtension.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/TracingInstrumentationExtension.kt index d51ca26f4..f23176c34 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/TracingInstrumentationExtension.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/TracingInstrumentationExtension.kt @@ -75,9 +75,16 @@ open class TracingInstrumentationExtension @Inject constructor(objects: ObjectFa enum class InstrumentationFeature(val integrationName: String) { /** - * When enabled the SDK will create spans for any CRUD operation performed by - * 'androidx.sqlite.db.SupportSQLiteOpenHelper' and 'androidx.room'. This feature uses bytecode - * manipulation. + * When enabled the SDK will create spans for database operations at two levels: + * + * **SQL statement execution** (`db.sql.query` spans): Wraps the low-level `SQLiteDriver` or + * `SupportSQLiteOpenHelper` so each SQL statement produces one or more spans. + * + * **DAO method execution** (`db.sql.room` spans): Wraps each public method on Room's generated + * `@Dao` `_Impl` classes, measuring the full DAO call end-to-end (transaction management, query + * execution, and cursor processing). Only for Room users on < Room 2.7. + * + * This feature uses bytecode manipulation. */ DATABASE("DatabaseInstrumentation"), diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt index fd4699f9c..0a22d2b68 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt @@ -10,6 +10,7 @@ import io.sentry.android.gradle.instrumentation.androidx.compose.ComposeNavigati import io.sentry.android.gradle.instrumentation.androidx.room.AndroidXRoomDao import io.sentry.android.gradle.instrumentation.androidx.sqlite.AndroidXSQLiteOpenHelper import io.sentry.android.gradle.instrumentation.androidx.sqlite.database.AndroidXSQLiteDatabase +import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.AndroidXSQLiteDriver import io.sentry.android.gradle.instrumentation.androidx.sqlite.statement.AndroidXSQLiteStatement import io.sentry.android.gradle.instrumentation.appstart.Application import io.sentry.android.gradle.instrumentation.appstart.ContentProvider @@ -90,10 +91,12 @@ abstract class SpanAddingClassVisitorFactory : ChainedInstrumentable( listOfNotNull( AndroidXSQLiteOpenHelper().takeIf { sentryModulesService.isNewDatabaseInstrEnabled() }, + AndroidXSQLiteDriver().takeIf { sentryModulesService.isSQLiteDriverInstrEnabled() }, AndroidXSQLiteDatabase().takeIf { sentryModulesService.isOldDatabaseInstrEnabled() }, AndroidXSQLiteStatement(androidXSqliteFrameWorkVersion).takeIf { sentryModulesService.isOldDatabaseInstrEnabled() }, + // Note that DAO spans no longer work on Room 2.7+ or Room 3.0+ due to Room API changes. AndroidXRoomDao().takeIf { sentryModulesService.isNewDatabaseInstrEnabled() || sentryModulesService.isOldDatabaseInstrEnabled() diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriver.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriver.kt new file mode 100644 index 000000000..f882bb4e0 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriver.kt @@ -0,0 +1,92 @@ +package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver + +import com.android.build.api.instrumentation.ClassContext +import io.sentry.android.gradle.instrumentation.ClassInstrumentable +import io.sentry.android.gradle.instrumentation.CommonClassVisitor +import io.sentry.android.gradle.instrumentation.MethodContext +import io.sentry.android.gradle.instrumentation.MethodInstrumentable +import io.sentry.android.gradle.instrumentation.SpanAddingClassVisitorFactory +import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.visitor.SetDriverMethodVisitor +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.MethodVisitor + +/** JVM type descriptor for `androidx.sqlite.SQLiteDriver`. */ +internal const val SQLITE_DRIVER_TYPE_DESCRIPTOR = "Landroidx/sqlite/SQLiteDriver;" + +/** + * Auto-instruments `SQLiteDriver` for all Room users by wrapping any driver passed to + * `RoomDatabase.Builder.setDriver(SQLiteDriver)`. + * + * In other words, this: + * ```kotlin + * val someDriver = AndroidSQLiteDriver() + * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") + * .setDriver(someDriver) + * .build() + * ``` + * + * becomes: + * ```kotlin + * val someDriver = AndroidSQLiteDriver() + * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") + * .setDriver(SentrySQLiteDriver.create(someDriver)) + * .build() + * ``` + * + * `SentrySQLiteDriver` protects against duplicate wrappings, allowing the visitor to wrap + * `SQLiteDriver` unconditionally. + * + * Coverage is limited to Room because SQLDelight + * [doesn't support `SQLiteDriver`](https://github.com/sqldelight/sqldelight/issues/6072) (it uses + * `SupportSQLiteOpenHelper`, which we auto-instrument via + * [AndroidXSQLiteOpenHelper][io.sentry.android.gradle.instrumentation.androidx.sqlite.AndroidXSQLiteOpenHelper]). + * To keep our implementation simple and build times fast, developers who use `SQLiteDriver` + * directly are expected to wrap it themselves. + */ +class AndroidXSQLiteDriver : ClassInstrumentable { + + override fun getVisitor( + instrumentableContext: ClassContext, + apiVersion: Int, + originalVisitor: ClassVisitor, + parameters: SpanAddingClassVisitorFactory.SpanAddingParameters, + ): ClassVisitor { + val currentClassName = instrumentableContext.currentClassData.className + + return CommonClassVisitor( + apiVersion = apiVersion, + classVisitor = originalVisitor, + className = currentClassName.substringAfterLast('.'), + methodInstrumentables = listOf(SetDriverMethodInstrumentable()), + parameters = parameters, + ) + } + + override fun isInstrumentable(data: ClassContext): Boolean = + data.currentClassData.className in TARGET_CLASSES + + companion object { + + // Currently covers Room 2 and Room 3 packages. Update as needed. + internal val TARGET_CLASSES = + setOf("androidx.room.RoomDatabase\$Builder", "androidx.room3.RoomDatabase\$Builder") + } +} + +class SetDriverMethodInstrumentable : MethodInstrumentable { + + override fun getVisitor( + instrumentableContext: MethodContext, + apiVersion: Int, + originalVisitor: MethodVisitor, + parameters: SpanAddingClassVisitorFactory.SpanAddingParameters, + ): MethodVisitor = SetDriverMethodVisitor(apiVersion, originalVisitor, instrumentableContext) + + override fun isInstrumentable(data: MethodContext): Boolean = + data.name == SET_DRIVER && data.descriptor?.startsWith(SET_DRIVER_DESCRIPTOR_PREFIX) == true + + companion object { + internal const val SET_DRIVER = "setDriver" + internal const val SET_DRIVER_DESCRIPTOR_PREFIX = "($SQLITE_DRIVER_TYPE_DESCRIPTOR)" + } +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitor.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitor.kt new file mode 100644 index 000000000..06dd12516 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitor.kt @@ -0,0 +1,39 @@ +package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.visitor + +import io.sentry.android.gradle.instrumentation.MethodContext +import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.SQLITE_DRIVER_TYPE_DESCRIPTOR +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Type +import org.objectweb.asm.commons.AdviceAdapter +import org.objectweb.asm.commons.Method + +class SetDriverMethodVisitor( + apiVersion: Int, + originalVisitor: MethodVisitor, + instrumentableContext: MethodContext, +) : + AdviceAdapter( + apiVersion, + originalVisitor, + instrumentableContext.access, + instrumentableContext.name, + instrumentableContext.descriptor, + ) { + + override fun onMethodEnter() { + // Inject at the start of RoomDatabase.Builder.setDriver(driver) so the method + // receives SentrySQLiteDriver.create(driver) instead of the raw driver (arg0). + loadArg(0) + invokeStatic(Type.getType(SENTRY_SQLITE_DRIVER_TYPE), Method(CREATE, SENTRY_CREATE_DESCRIPTOR)) + storeArg(0) + } + + companion object { + internal const val CREATE = "create" + // Must match SentrySQLiteDriver.create(SQLiteDriver): SQLiteDriver in sentry-android-sqlite: + // https://github.com/getsentry/sentry-java/blob/main/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt + internal const val SENTRY_CREATE_DESCRIPTOR = + "($SQLITE_DRIVER_TYPE_DESCRIPTOR)$SQLITE_DRIVER_TYPE_DESCRIPTOR" + internal const val SENTRY_SQLITE_DRIVER_TYPE = "Lio/sentry/sqlite/SentrySQLiteDriver;" + } +} 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 29ba71bb3..b7d5aacf6 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 @@ -72,6 +72,19 @@ abstract class SentryModulesService : sentryModules.isAtLeast(SentryModules.SENTRY_ANDROID_SQLITE, SentryVersions.VERSION_SQLITE) && parameters.features.get().contains(InstrumentationFeature.DATABASE) + /** + * Returns true when the owning app uses a version of sentry-android-sqlite that contains + * `SentrySQLiteDriver` and the DATABASE feature is enabled. + * + * For simplicity we avoid gating on the Room version and rely on visitor behavior instead (no-ops + * for versions of Room without a `SQLiteDriver`). + */ + fun isSQLiteDriverInstrEnabled(): Boolean = + sentryModules.isAtLeast( + SentryModules.SENTRY_ANDROID_SQLITE, + SentryVersions.VERSION_SQLITE_DRIVER, + ) && parameters.features.get().contains(InstrumentationFeature.DATABASE) + fun isOldDatabaseInstrEnabled(): Boolean = !isNewDatabaseInstrEnabled() && sentryModules.isAtLeast( 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 8485bde93..c7e96281e 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 @@ -33,6 +33,7 @@ internal object SentryVersions { internal val VERSION_LOGCAT = SemVer(6, 17, 0) 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_ANDROID_OKHTTP_LISTENER = SemVer(6, 20, 0) internal val VERSION_OKHTTP = SemVer(7, 0, 0) } diff --git a/plugin-build/src/test/kotlin/androidx/sqlite/SQLiteDriver.kt b/plugin-build/src/test/kotlin/androidx/sqlite/SQLiteDriver.kt new file mode 100644 index 000000000..7e3944dcf --- /dev/null +++ b/plugin-build/src/test/kotlin/androidx/sqlite/SQLiteDriver.kt @@ -0,0 +1,12 @@ +package androidx.sqlite + +/** + * Minimal stub of `androidx.sqlite.SQLiteDriver` so ASM can resolve the type referenced by the + * instrumented bytecode. + * + * Must not coexist with androidx.sqlite >= 2.5 on the test classpath: that version introduces the + * real `SQLiteDriver`, which would collide with this stub. Safe today because `libs.sqlite` is + * pinned below 2.5 and `testImplementationAar` does not pull transitive dependencies (Room 2.7+ + * depends on androidx.sqlite 2.5+, but `Aar2JarPlugin` sets `isTransitive = false`). + */ +interface SQLiteDriver diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/VisitorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/VisitorTest.kt index 56ba455bf..819d56cc3 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/VisitorTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/VisitorTest.kt @@ -5,6 +5,7 @@ import io.sentry.android.gradle.instrumentation.androidx.compose.ComposeNavigati import io.sentry.android.gradle.instrumentation.androidx.room.AndroidXRoomDao import io.sentry.android.gradle.instrumentation.androidx.sqlite.AndroidXSQLiteOpenHelper import io.sentry.android.gradle.instrumentation.androidx.sqlite.database.AndroidXSQLiteDatabase +import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.AndroidXSQLiteDriver import io.sentry.android.gradle.instrumentation.androidx.sqlite.statement.AndroidXSQLiteStatement import io.sentry.android.gradle.instrumentation.appstart.Application import io.sentry.android.gradle.instrumentation.appstart.ContentProvider @@ -122,6 +123,20 @@ class VisitorTest( AndroidXSQLiteStatement(SemVer(2, 3, 0)), null, ), + // RoomDatabase$Builder fixtures: see SQLiteDriverBytecodeTestUtil (extracted from published + // AARs). + arrayOf( + "androidxRoom", + "RoomDatabase\$Builder", + AndroidXSQLiteDriver(), + TestClassContext("androidx.room.RoomDatabase\$Builder"), + ), + arrayOf( + "androidxRoom", + "RoomDatabase3\$Builder", + AndroidXSQLiteDriver(), + TestClassContext("androidx.room3.RoomDatabase\$Builder"), + ), roomDaoTestParameters("DeleteAndReturnUnit"), roomDaoTestParameters("InsertAndReturnLong"), roomDaoTestParameters("InsertAndReturnUnit"), diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriverTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriverTest.kt new file mode 100644 index 000000000..458413a67 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/AndroidXSQLiteDriverTest.kt @@ -0,0 +1,95 @@ +@file:Suppress("UnstableApiUsage") + +package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver + +import io.sentry.android.gradle.instrumentation.ChainedInstrumentable +import io.sentry.android.gradle.instrumentation.ClassInstrumentable +import io.sentry.android.gradle.instrumentation.fakes.TestClassContext +import io.sentry.android.gradle.instrumentation.fakes.TestSpanAddingParameters +import java.io.FileInputStream +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes + +class AndroidXSQLiteDriverTest { + + @get:Rule val tmpDir = TemporaryFolder() + + private val instrumentable = AndroidXSQLiteDriver() + + @Test + fun `isInstrumentable returns true for RoomDatabase Builder classes`() { + assertTrue( + instrumentable.isInstrumentable(TestClassContext("androidx.room.RoomDatabase\$Builder")) + ) + assertTrue( + instrumentable.isInstrumentable(TestClassContext("androidx.room3.RoomDatabase\$Builder")) + ) + } + + @Test + fun `isInstrumentable returns false for unrelated classes`() { + assertFalse(instrumentable.isInstrumentable(TestClassContext("com.example.RoomConfig"))) + assertFalse(instrumentable.isInstrumentable(TestClassContext("io.sentry.Sentry"))) + assertFalse(instrumentable.isInstrumentable(TestClassContext("com.example.FakeSetDriver"))) + } + + @Test + fun `ChainedInstrumentable does not instrument unrelated classes`() { + val className = "com.example.NoSetDriver" + val originalBytes = loadNoSetDriverFixtureBytes() + val instrumentedBytes = instrumentThroughChain(className, originalBytes) + + assertEquals(0, SQLiteDriverBytecodeTestUtil.countWrapCalls(instrumentedBytes)) + } + + @Test + fun `does not wrap when a visited class has no setDriver method`() { + // The Room < 2.7 production path: RoomDatabase$Builder matches the allowlist (so getVisitor + // runs), but the class has no setDriver(SQLiteDriver) and must pass through without a wrap. + val instrumentedBytes = + instrumentDirectly("com.example.NoSetDriver", loadNoSetDriverFixtureBytes()) + + assertEquals(0, SQLiteDriverBytecodeTestUtil.countWrapCalls(instrumentedBytes)) + } + + private fun instrumentThroughChain(className: String, bytes: ByteArray): ByteArray = + instrument(ChainedInstrumentable(listOf(instrumentable)), className, bytes) + + private fun instrumentDirectly(className: String, bytes: ByteArray): ByteArray = + instrument(instrumentable, className, bytes) + + private fun instrument( + classInstrumentable: ClassInstrumentable, + className: String, + bytes: ByteArray, + ): ByteArray { + val classReader = ClassReader(bytes) + val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS) + val classVisitor = + classInstrumentable.getVisitor( + TestClassContext(className), + Opcodes.ASM9, + classWriter, + parameters = TestSpanAddingParameters(inMemoryDir = tmpDir.root), + ) + classReader.accept(classVisitor, ClassReader.SKIP_FRAMES) + return classWriter.toByteArray() + } + + /** + * `NoSetDriver.class`: hand-compiled `public class NoSetDriver { int unrelated(int) }`. Shape is + * irrelevant / any class without `setDriver(SQLiteDriver)` works. + */ + private fun loadNoSetDriverFixtureBytes(): ByteArray = + FileInputStream( + "src/test/resources/testFixtures/instrumentation/androidxSqliteDriver/NoSetDriver.class" + ) + .use { it.readBytes() } +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SQLiteDriverBytecodeTestUtil.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SQLiteDriverBytecodeTestUtil.kt new file mode 100644 index 000000000..918f6d5c1 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SQLiteDriverBytecodeTestUtil.kt @@ -0,0 +1,55 @@ +package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver + +import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.visitor.SetDriverMethodVisitor +import java.io.FileInputStream +import org.objectweb.asm.ClassReader +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type +import org.objectweb.asm.tree.ClassNode +import org.objectweb.asm.tree.MethodInsnNode +import org.objectweb.asm.tree.MethodNode + +internal object SQLiteDriverBytecodeTestUtil { + + private const val FIXTURES_ROOT = "src/test/resources/testFixtures/instrumentation/androidxRoom" + + /** + * Room `Builder` bytecode fixtures extracted from published AARs: + * - `RoomDatabase$Builder.class`: `androidx.room:room-runtime-android:2.7.0` + * - `RoomDatabase3$Builder.class`: `androidx.room3:room3-runtime-android:3.0.0-alpha06` + * + * Extract from Google Maven by unzipping each AAR's `classes.jar` and copying + * `androidx/room/RoomDatabase$Builder.class` (or `androidx/room3/...`). + * + * `VisitorTest` needs matching Room runtime AARs (and coroutines) on the test classpath so ASM + * can resolve types referenced by the real bytecode. + */ + private val CLASS_NAME_TO_FIXTURE = + mapOf( + "androidx.room.RoomDatabase\$Builder" to "RoomDatabase\$Builder", + "androidx.room3.RoomDatabase\$Builder" to "RoomDatabase3\$Builder", + ) + + fun loadRoomBuilderFixture(className: String): ByteArray { + val fixtureName = + CLASS_NAME_TO_FIXTURE[className] ?: error("No committed fixture for class $className") + return FileInputStream("$FIXTURES_ROOT/$fixtureName.class").use { it.readBytes() } + } + + fun isWrapCall(insn: MethodInsnNode): Boolean = + insn.opcode == Opcodes.INVOKESTATIC && + insn.owner == Type.getType(SetDriverMethodVisitor.SENTRY_SQLITE_DRIVER_TYPE).internalName && + insn.name == SetDriverMethodVisitor.CREATE && + insn.desc == SetDriverMethodVisitor.SENTRY_CREATE_DESCRIPTOR + + fun isSetDriverDescriptor(descriptor: String): Boolean = + descriptor.startsWith(SetDriverMethodInstrumentable.SET_DRIVER_DESCRIPTOR_PREFIX) + + fun countWrapCalls(bytes: ByteArray): Int { + val classNode = ClassNode().also { ClassReader(bytes).accept(it, 0) } + return classNode.methods.sumOf(::countWrapCalls) + } + + fun countWrapCalls(method: MethodNode): Int = + method.instructions.filterIsInstance().count(::isWrapCall) +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SetDriverMethodInstrumentableTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SetDriverMethodInstrumentableTest.kt new file mode 100644 index 000000000..421dabeb0 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/SetDriverMethodInstrumentableTest.kt @@ -0,0 +1,48 @@ +package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver + +import io.sentry.android.gradle.instrumentation.MethodContext +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Test +import org.objectweb.asm.Opcodes + +class SetDriverMethodInstrumentableTest { + + private val instrumentable = SetDriverMethodInstrumentable() + + @Test + fun `isInstrumentable returns true for setDriver with SQLiteDriver parameter`() { + assertTrue( + instrumentable.isInstrumentable( + methodContext( + SetDriverMethodInstrumentable.SET_DRIVER, + "${SetDriverMethodInstrumentable.SET_DRIVER_DESCRIPTOR_PREFIX}Landroidx/room/RoomDatabase\$Builder;", + ) + ) + ) + } + + @Test + fun `isInstrumentable returns false for unrelated method names`() { + assertFalse(instrumentable.isInstrumentable(methodContext("build", "()V"))) + } + + @Test + fun `isInstrumentable returns false for setDriver with non-SQLiteDriver descriptor`() { + assertFalse( + instrumentable.isInstrumentable( + methodContext(SetDriverMethodInstrumentable.SET_DRIVER, "(Ljava/lang/Object;)V") + ) + ) + } + + @Test + fun `isInstrumentable returns false when descriptor is null`() { + assertFalse( + instrumentable.isInstrumentable(methodContext(SetDriverMethodInstrumentable.SET_DRIVER, null)) + ) + } + + private fun methodContext(name: String, descriptor: String?) = + MethodContext(Opcodes.ACC_PUBLIC, name, descriptor, null, null) +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitorTest.kt new file mode 100644 index 000000000..92f728473 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/driver/visitor/SetDriverMethodVisitorTest.kt @@ -0,0 +1,89 @@ +@file:Suppress("UnstableApiUsage") + +package io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.visitor + +import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.AndroidXSQLiteDriver +import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.SQLiteDriverBytecodeTestUtil +import io.sentry.android.gradle.instrumentation.androidx.sqlite.driver.SetDriverMethodInstrumentable +import io.sentry.android.gradle.instrumentation.fakes.TestClassContext +import io.sentry.android.gradle.instrumentation.fakes.TestSpanAddingParameters +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes +import org.objectweb.asm.tree.ClassNode +import org.objectweb.asm.tree.FieldInsnNode +import org.objectweb.asm.tree.MethodInsnNode +import org.objectweb.asm.tree.MethodNode + +class SetDriverMethodVisitorTest { + + @get:Rule val tmpDir = TemporaryFolder() + + @Test + fun `wraps the driver parameter at the start of Room 2_x Builder setDriver`() { + assertSetDriverWrappedOnce("androidx.room.RoomDatabase\$Builder") + } + + @Test + fun `wraps the driver parameter at the start of Room 3_x Builder setDriver`() { + assertSetDriverWrappedOnce("androidx.room3.RoomDatabase\$Builder") + } + + private fun assertSetDriverWrappedOnce(className: String) { + val instrumentedBytes = instrument(className) + val setDriverMethod = findSetDriverMethod(instrumentedBytes) + + assertEquals( + 1, + SQLiteDriverBytecodeTestUtil.countWrapCalls(setDriverMethod), + "setDriver should contain exactly one wrap", + ) + assertTrue( + wrapPrecedesOriginalBody(setDriverMethod), + "SentrySQLiteDriver.create() must run before the original setDriver body", + ) + } + + private fun instrument(className: String): ByteArray { + val bytes = SQLiteDriverBytecodeTestUtil.loadRoomBuilderFixture(className) + val classReader = ClassReader(bytes) + val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS) + val classVisitor = + AndroidXSQLiteDriver() + .getVisitor( + TestClassContext(className), + Opcodes.ASM9, + classWriter, + parameters = TestSpanAddingParameters(inMemoryDir = tmpDir.root), + ) + classReader.accept(classVisitor, ClassReader.SKIP_FRAMES) + return classWriter.toByteArray() + } + + private fun findSetDriverMethod(bytes: ByteArray): MethodNode { + val classNode = ClassNode().also { ClassReader(bytes).accept(it, 0) } + return classNode.methods.first { + it.name == SetDriverMethodInstrumentable.SET_DRIVER && + SQLiteDriverBytecodeTestUtil.isSetDriverDescriptor(it.desc) + } + } + + private fun wrapPrecedesOriginalBody(method: MethodNode): Boolean { + val realInsns = method.instructions.toArray().filter { it.opcode >= 0 } + val wrapIndex = + realInsns.indexOfFirst { it is MethodInsnNode && SQLiteDriverBytecodeTestUtil.isWrapCall(it) } + assertTrue(wrapIndex >= 0, "setDriver has no SentrySQLiteDriver.create call") + val firstOriginalBodyIndex = + realInsns.indexOfFirst { + (it is MethodInsnNode && !SQLiteDriverBytecodeTestUtil.isWrapCall(it)) || + it is FieldInsnNode + } + assertTrue(firstOriginalBodyIndex >= 0, "setDriver fixture has no recognizable original body") + return wrapIndex < firstOriginalBodyIndex + } +} 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 50c8c5b0e..fb444b2bc 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 @@ -4,6 +4,7 @@ import io.sentry.BuildConfig import io.sentry.android.gradle.extensions.InstrumentationFeature import io.sentry.android.gradle.util.AgpVersions import io.sentry.android.gradle.util.SemVer +import io.sentry.android.gradle.util.SentryVersions import io.sentry.android.gradle.verifyDebugMetaPropertiesNotInApk import io.sentry.android.gradle.verifyDependenciesReportAndroid import io.sentry.android.gradle.verifyIntegrationList @@ -16,6 +17,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue +import org.gradle.testkit.runner.BuildResult import org.gradle.testkit.runner.TaskOutcome import org.gradle.util.GradleVersion import org.hamcrest.CoreMatchers.`is` @@ -529,6 +531,82 @@ class SentryPluginTest : } } + @Test + fun `applies sqliteDriver instrumentable when sentry gate passes with room2`() { + val build = + buildDatabaseInstrumentation(SQLITE, ROOM2_AT_DRIVER_FLOOR, SENTRY_ANDROID_SQLITE_DRIVER) + + assertInstrumentableChain( + build, + "AndroidXSQLiteOpenHelper", + "AndroidXSQLiteDriver", + "AndroidXRoomDao", + ) + } + + @Test + fun `applies sqliteDriver instrumentable when sentry gate passes with room3`() { + val build = + buildDatabaseInstrumentation(SQLITE, ROOM3_AT_DRIVER_FLOOR, SENTRY_ANDROID_SQLITE_DRIVER) + + assertInstrumentableChain( + build, + "AndroidXSQLiteOpenHelper", + "AndroidXSQLiteDriver", + "AndroidXRoomDao", + ) + } + + @Test + fun `does not apply sqliteDriver instrumentable when sentry gate fails without room on classpath`() { + val build = buildDatabaseInstrumentation(SQLITE, SENTRY_ANDROID_SQLITE_OPEN_HELPER) + + assertInstrumentableChain(build, "AndroidXSQLiteOpenHelper", "AndroidXRoomDao") + } + + @Test + fun `does not apply sqliteDriver instrumentable when sentry gate fails with room2`() { + val build = + buildDatabaseInstrumentation(SQLITE, ROOM2_AT_DRIVER_FLOOR, SENTRY_ANDROID_SQLITE_OPEN_HELPER) + + assertInstrumentableChain(build, "AndroidXSQLiteOpenHelper", "AndroidXRoomDao") + } + + @Test + fun `does not apply sqliteDriver instrumentable when sentry gate fails with room3`() { + val build = + buildDatabaseInstrumentation(SQLITE, ROOM3_AT_DRIVER_FLOOR, SENTRY_ANDROID_SQLITE_OPEN_HELPER) + + assertInstrumentableChain(build, "AndroidXSQLiteOpenHelper", "AndroidXRoomDao") + } + + @Test + fun `applies sqliteDriver instrumentable when sentry gate passes without room on classpath`() { + val build = buildDatabaseInstrumentation(SQLITE, SENTRY_ANDROID_SQLITE_DRIVER) + + assertInstrumentableChain( + build, + "AndroidXSQLiteOpenHelper", + "AndroidXSQLiteDriver", + "AndroidXRoomDao", + ) + } + + @Test + fun `applies sqliteDriver instrumentable when sentry gate passes with room below 2_7`() { + val build = + buildDatabaseInstrumentation(SQLITE, ROOM2_BELOW_DRIVER_FLOOR, SENTRY_ANDROID_SQLITE_DRIVER) + + assertInstrumentableChain( + build, + "AndroidXSQLiteOpenHelper", + "AndroidXSQLiteDriver", + "AndroidXRoomDao", + ) + } + + // Database path when sentry-android-sqlite is absent (old AndroidXSQLiteDatabase/Statement). + // Orthogonal to the SQLiteDriver gate matrix above. @Test fun `apply old Database instrumentable when app does not depend on sentry-android-sqlite`() { applyTracingInstrumentation( @@ -1106,6 +1184,7 @@ class SentryPluginTest : excludes: Set = emptySet(), sdkVersion: String = "7.1.0", forceInstrumentDependencies: Boolean = true, + minSdk: Int? = null, ) { appBuildFile.appendText( // language=Groovy @@ -1131,8 +1210,57 @@ class SentryPluginTest : excludes = ["${excludes.joinToString()}"] } } + ${ + minSdk?.let { + """ + android { + defaultConfig { + minSdkVersion $it + } + } + """ + } ?: "" + } """ .trimIndent() ) } + + private fun buildDatabaseInstrumentation(vararg dependencies: String): BuildResult { + applyTracingInstrumentation( + features = setOf(InstrumentationFeature.DATABASE), + dependencies = dependencies.toSet(), + appStart = false, + logcat = false, + minSdk = DRIVER_PATH_MIN_SDK, + ) + return runner.appendArguments(":app:assembleDebug", "--info").build() + } + + private fun assertInstrumentableChain(build: BuildResult, vararg expected: String) { + assertEquals(expected.toList(), instrumentables(build)) + } + + private fun instrumentables(build: BuildResult): List { + val line = + build.output.lines().first { + it.contains("[sentry] Instrumentable: ChainedInstrumentable(instrumentables=") + } + val prefix = "ChainedInstrumentable(instrumentables=" + val start = line.indexOf(prefix) + prefix.length + val end = line.lastIndexOf(')') + return line.substring(start, end).split(", ").filter { it.isNotEmpty() } + } + + companion object { + private const val SQLITE = "androidx.sqlite:sqlite:2.6.2" + private const val SENTRY_ANDROID_SQLITE_OPEN_HELPER = "io.sentry:sentry-android-sqlite:6.21.0" + private val SENTRY_ANDROID_SQLITE_DRIVER = + "io.sentry:sentry-android-sqlite:${SentryVersions.VERSION_SQLITE_DRIVER}" + private const val ROOM2_AT_DRIVER_FLOOR = "androidx.room:room-runtime:2.7.0" + private const val ROOM2_BELOW_DRIVER_FLOOR = "androidx.room:room-runtime:2.6.1" + private const val ROOM3_AT_DRIVER_FLOOR = "androidx.room3:room3-runtime:3.0.0-alpha06" + /** androidx.sqlite 2.6.x and room3-runtime both require minSdk 23 in the test fixture. */ + private const val DRIVER_PATH_MIN_SDK = 23 + } } diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesServiceTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesServiceTest.kt new file mode 100644 index 000000000..662bbce5e --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryModulesServiceTest.kt @@ -0,0 +1,168 @@ +package io.sentry.android.gradle.util + +import io.sentry.android.gradle.extensions.InstrumentationFeature +import io.sentry.android.gradle.services.SentryModulesService +import java.io.File +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.gradle.api.artifacts.ModuleIdentifier +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class SentryModulesServiceTest { + + class Fixture { + + fun getSut( + tmpDir: File, + features: Set = emptySet(), + sentryModules: Map = emptyMap(), + ): SentryModulesService { + val fakeProject = ProjectBuilder.builder().withProjectDir(tmpDir).build() + + val featureProvider = fakeProject.provider { features } + val logcatEnabled = fakeProject.provider { true } + val sourceContextEnabled = fakeProject.provider { false } + val dexguardEnabled = fakeProject.provider { false } + val appStartEnabled = fakeProject.provider { false } + + val serviceProvider = + SentryModulesService.register( + fakeProject, + featureProvider, + logcatEnabled, + sourceContextEnabled, + dexguardEnabled, + appStartEnabled, + ) + val service = serviceProvider.get() + service.sentryModules = sentryModules + return service + } + } + + @get:Rule val testProjectDir = TemporaryFolder() + + private val fixture = Fixture() + + private fun sqliteDriverSentryModules(): Map = + mapOf(SentryModules.SENTRY_ANDROID_SQLITE to SentryVersions.VERSION_SQLITE_DRIVER) + + @Test + fun `isSQLiteDriverInstrEnabled is true when sentry-android-sqlite meets threshold and DATABASE is enabled`() { + val service = + fixture.getSut( + tmpDir = testProjectDir.root, + features = setOf(InstrumentationFeature.DATABASE), + sentryModules = sqliteDriverSentryModules(), + ) + + assertTrue(service.isSQLiteDriverInstrEnabled()) + } + + @Test + fun `isSQLiteDriverInstrEnabled is false when sentry-android-sqlite is absent from classpath`() { + val service = + fixture.getSut( + tmpDir = testProjectDir.root, + features = setOf(InstrumentationFeature.DATABASE), + sentryModules = emptyMap(), + ) + + assertFalse(service.isSQLiteDriverInstrEnabled()) + } + + @Test + fun `isSQLiteDriverInstrEnabled is false when sentry-android-sqlite is just below VERSION_SQLITE_DRIVER`() { + val belowThreshold = + SemVer( + SentryVersions.VERSION_SQLITE_DRIVER.major, + SentryVersions.VERSION_SQLITE_DRIVER.minor - 1, + 99, + ) + val service = + fixture.getSut( + tmpDir = testProjectDir.root, + features = setOf(InstrumentationFeature.DATABASE), + sentryModules = mapOf(SentryModules.SENTRY_ANDROID_SQLITE to belowThreshold), + ) + + assertFalse(service.isSQLiteDriverInstrEnabled()) + } + + @Test + fun `isSQLiteDriverInstrEnabled is false when sentry-android-sqlite is one minor below VERSION_SQLITE_DRIVER`() { + val belowThreshold = + SemVer( + SentryVersions.VERSION_SQLITE_DRIVER.major, + SentryVersions.VERSION_SQLITE_DRIVER.minor - 1, + SentryVersions.VERSION_SQLITE_DRIVER.patch, + ) + val service = + fixture.getSut( + tmpDir = testProjectDir.root, + features = setOf(InstrumentationFeature.DATABASE), + sentryModules = mapOf(SentryModules.SENTRY_ANDROID_SQLITE to belowThreshold), + ) + + assertFalse(service.isSQLiteDriverInstrEnabled()) + } + + @Test + fun `isSQLiteDriverInstrEnabled is false when DATABASE is disabled`() { + val service = + fixture.getSut( + tmpDir = testProjectDir.root, + features = emptySet(), + sentryModules = sqliteDriverSentryModules(), + ) + + assertFalse(service.isSQLiteDriverInstrEnabled()) + } + + @Test + fun `VERSION_SQLITE_DRIVER is greater than or equal to VERSION_SQLITE`() { + // Gating relies on the presence of the open helper whenever the driver is present: both + // instrumentables fire together and we rely on SentrySQLiteDriver.create to dedup the + // SupportSQLiteDriver bridge case. + assertTrue(SentryVersions.VERSION_SQLITE_DRIVER >= SentryVersions.VERSION_SQLITE) + } + + @Test + fun `between VERSION_SQLITE and VERSION_SQLITE_DRIVER only the open-helper path is on`() { + val service = + fixture.getSut( + tmpDir = testProjectDir.root, + features = setOf(InstrumentationFeature.DATABASE), + sentryModules = + mapOf( + SentryModules.SENTRY_ANDROID_SQLITE to SentryVersions.VERSION_SQLITE, + SentryModules.SENTRY_ANDROID_CORE to SentryVersions.VERSION_PERFORMANCE, + ), + ) + + assertTrue(service.isNewDatabaseInstrEnabled()) + assertFalse(service.isSQLiteDriverInstrEnabled()) + assertFalse(service.isOldDatabaseInstrEnabled()) + } + + @Test + fun `at VERSION_SQLITE_DRIVER the open-helper path is also on and the old path is off`() { + val service = + fixture.getSut( + tmpDir = testProjectDir.root, + features = setOf(InstrumentationFeature.DATABASE), + sentryModules = + mapOf( + SentryModules.SENTRY_ANDROID_SQLITE to SentryVersions.VERSION_SQLITE_DRIVER, + SentryModules.SENTRY_ANDROID_CORE to SentryVersions.VERSION_PERFORMANCE, + ), + ) + + assertTrue(service.isSQLiteDriverInstrEnabled()) + assertTrue(service.isNewDatabaseInstrEnabled()) // superset relationship + assertFalse(service.isOldDatabaseInstrEnabled()) // suppressed by !isNewDatabaseInstrEnabled() + } +} diff --git a/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase$Builder.class b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase$Builder.class new file mode 100644 index 000000000..88e8733f1 Binary files /dev/null and b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase$Builder.class differ diff --git a/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase3$Builder.class b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase3$Builder.class new file mode 100644 index 000000000..57cc73de2 Binary files /dev/null and b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxRoom/RoomDatabase3$Builder.class differ diff --git a/plugin-build/src/test/resources/testFixtures/instrumentation/androidxSqliteDriver/NoSetDriver.class b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxSqliteDriver/NoSetDriver.class new file mode 100644 index 000000000..7d3748a1e Binary files /dev/null and b/plugin-build/src/test/resources/testFixtures/instrumentation/androidxSqliteDriver/NoSetDriver.class differ