diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b7e2a85b..dd8fc49b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Automatically generates spans for all SQLite statements - To use it, pass your `SQLiteDriver` to `SentrySQLiteDriver.create(...)` - You'll need `androidx.sqlite:sqlite` (2.5.0+) on your app's classpath (Room usually provides it for you). androidx.sqlite 2.6.0+ requires minSdk 23. + - The Room 2.7+ `androidx.sqlite.driver.SupportSQLiteDriver` bridge adapter is recognized and skipped by `SentrySQLiteDriver.create(...)` so apps that wrap both the open helper and the bridge driver do not emit duplicate spans. Spans come from the open helper layer in that configuration. - See https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/ for more details, including info about migrating from `SentrySupportSQLiteOpenHelper` ## 8.43.1 diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt index 7c7e24ac07..2c43a34422 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -21,10 +21,9 @@ import io.sentry.SentryLevel * .build() * ``` * - * **Warning:** Do not use [SentrySQLiteDriver] together with - * [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper] on the - * same database file. Both wrappers instrument at different layers, so combining them will produce - * duplicate spans for every SQL statement. + * Note: In order to avoid duplicate spans, wrapping no-ops in the case of the + * `androidx.sqlite.driver.SupportSQLiteDriver`. Wrap the open helper passed to its constructor via + * `SentrySupportSQLiteOpenHelper` instead. * * @param delegate The [SQLiteDriver] instance to delegate calls to. */ @@ -68,8 +67,23 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite public companion object { + /** + * Name of the bridge adapter often used with Room 2.7+. It implements the `SQLiteDriver` + * interface and its constructor consumes a `SupportSQLiteOpenHelper`. (Users of the Sentry + * Android Gradle Plugin will have the `SupportSQLiteOpenHelper` wrapped for them + * automatically.) We deliberately avoid wrapping the adapter to prevent duplicate spans. + * + * String (rather than an `is` check) lets us avoid a compile-time dependency on + * androidx.sqlite:sqlite-framework. + */ + private const val SUPPORT_SQLITE_DRIVER_FQN = "androidx.sqlite.driver.SupportSQLiteDriver" + @JvmStatic public fun create(delegate: SQLiteDriver): SQLiteDriver = - delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate) + if (delegate is SentrySQLiteDriver || delegate.javaClass.name == SUPPORT_SQLITE_DRIVER_FQN) { + delegate + } else { + SentrySQLiteDriver(delegate) + } } } diff --git a/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt new file mode 100644 index 0000000000..2de7f1d38f --- /dev/null +++ b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt @@ -0,0 +1,18 @@ +package androidx.sqlite.driver + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver + +/** + * Minimal stub of `androidx.sqlite.driver.SupportSQLiteDriver` (which lives in + * `androidx.sqlite:sqlite-framework`, not on this module's compile/test classpath) for verifying + * behavior of `SentrySQLiteDriver.create(SupportSQLiteDriver)`. + */ +internal class SupportSQLiteDriver : SQLiteDriver { + + override val hasConnectionPool: Boolean = false + + override fun open(fileName: String): SQLiteConnection { + throw UnsupportedOperationException("Test stub; not for runtime use") + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt index 9b2345a975..5816f3d859 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -3,6 +3,7 @@ package io.sentry.sqlite import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteDriver import androidx.sqlite.SQLiteStatement +import androidx.sqlite.driver.SupportSQLiteDriver import io.sentry.IScopes import io.sentry.Sentry import io.sentry.SentryIntegrationPackageStorage @@ -64,6 +65,16 @@ class SentrySQLiteDriverTest { assertSame(wrapped, doubleWrapped) } + @Test + fun `create with SupportSQLiteDriver bridge returns same instance without wrapping`() { + val bridge = SupportSQLiteDriver() + + val result = SentrySQLiteDriver.create(bridge) + + assertSame(bridge, result) + assertFalse(result is SentrySQLiteDriver) + } + @Test fun `hasConnectionPool forwards delegate value when supported`() { whenever(fixture.mockDriver.hasConnectionPool).thenReturn(true) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt index 14582fe305..fd80a5aae1 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt @@ -87,6 +87,11 @@ internal val OPENHELPER_ROOM = .trimIndent(), ) +// Bridge demos run the same SQL as the driver paths; spans come from the open-helper layer. +internal val BRIDGE_DIRECT = DRIVER_DIRECT + +internal val BRIDGE_ROOM2 = DRIVER_ROOM2 + internal val OPENHELPER_SQLDELIGHT = DisplayInfo( sql = diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt index 1ff6828a75..9454002f98 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -1,6 +1,7 @@ package io.sentry.samples.android.sqlite import android.os.Bundle +import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -33,6 +34,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchColors @@ -73,6 +77,7 @@ import kotlinx.coroutines.withContext private val SentryPink = Color(0xFFC85B9C) private val SentryPurple = Color(0xFF7B52FB) +private val SentryOrange = Color(0xFFE8743F) private val SentryRed = Color(0xFFF55459) /** Intro text, surfaced via the "?" tooltip next to the "Run it" header. */ @@ -88,10 +93,33 @@ private val CONTROL_SECTION_GAP = TOGGLE_SECTION_GAP * 2 private val SECTION_HEADER_HEIGHT = 28.dp -/** Which sentry-android-sqlite integration the demo buttons currently target. */ -private enum class Integration(val color: Color, val apiName: String) { - DRIVER(SentryPurple, "SQLiteDriver"), - OPEN_HELPER(SentryPink, "SupportSQLiteOpenHelper"), +/** Which sentry-android-sqlite integration the demo currently targets. */ +private enum class IntegrationMode( + val color: Color, + val segmentLabel: String, + val apiName: String, + val subtitle: String, +) { + DRIVER( + SentryPurple, + "SQLiteDriver", + "SQLiteDriver", + "SentrySQLiteDriver.create(BundledSQLiteDriver)", + ), + OPEN_HELPER( + SentryPink, + "OpenHelper", + "SupportSQLiteOpenHelper", + "SentrySupportSQLiteOpenHelper.create(...)", + ), + // Not directly-supported, but lets us verify behavior when both the DRIVER and OPEN_HELPER + // integrations are used together via the SupportSQLiteDriver bridge. + BRIDGE( + SentryOrange, + "Bridge", + "SupportSQLiteDriver bridge", + "SentrySQLiteDriver.create(SupportSQLiteDriver(Sentry helper))", + ), } /** @@ -107,11 +135,24 @@ private class DemoVariant( ) /** - * A single demo button in the list. [driver] / [openHelper] hold the variant for each integration; - * a null variant means the row doesn't apply to that integration and renders dimmed, explaining why - * on click (Room 3 is driver-only; SQLDelight is open-helper-only). + * A single demo button in the list. [driver] / [openHelper] / [bridge] hold the variant for each + * integration; a null variant means the row doesn't apply and renders dimmed (e.g., Room 3 is + * driver-only; SQLDelight is open-helper-only; etc.). */ -private class DemoRow(val label: String, val driver: DemoVariant?, val openHelper: DemoVariant?) +private class DemoRow( + val label: String, + val driver: DemoVariant?, + val openHelper: DemoVariant?, + val bridge: DemoVariant?, +) { + + fun variantFor(mode: IntegrationMode): DemoVariant? = + when (mode) { + IntegrationMode.DRIVER -> driver + IntegrationMode.OPEN_HELPER -> openHelper + IntegrationMode.BRIDGE -> bridge + } +} // The demo buttons, top to bottom, paired with each integration's variant. Pure data — the actual // SQL lives in SqlStatements, dispatched by id. @@ -133,6 +174,13 @@ private val DEMO_ROWS = op = "db.sql.openhelper-direct", displayInfo = OPENHELPER_DIRECT, ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_DIRECT, + transactionName = "Bridge stack — Direct", + op = "db.sql.bridge-direct", + displayInfo = BRIDGE_DIRECT, + ), ), DemoRow( label = "Room 2", @@ -150,6 +198,13 @@ private val DEMO_ROWS = op = "db.sql.openhelper-room", displayInfo = OPENHELPER_ROOM, ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_ROOM2, + transactionName = "Bridge stack — Room 2", + op = "db.sql.bridge-room2", + displayInfo = BRIDGE_ROOM2, + ), ), DemoRow( label = "Room 3", @@ -161,6 +216,7 @@ private val DEMO_ROWS = displayInfo = DRIVER_ROOM3, ), openHelper = null, // Room 3 only runs on the SQLiteDriver path. + bridge = null, ), DemoRow( label = "SQLDelight", @@ -172,6 +228,7 @@ private val DEMO_ROWS = op = "db.sql.openhelper-sqldelight", displayInfo = OPENHELPER_SQLDELIGHT, ), + bridge = null, ), ) @@ -187,6 +244,7 @@ private val DEMO_ROWS = class SQLiteActivity : ComponentActivity() { private var latestResult by mutableStateOf("") + private var warmUpErrors by mutableStateOf("") private var sqlDetail by mutableStateOf(SQL_DETAIL_HINT) private var heavyWork by mutableStateOf(false) @@ -198,8 +256,8 @@ class SQLiteActivity : ComponentActivity() { */ private var shareScreenTrace by mutableStateOf(false) - /** Which integration the demo buttons target. Switching it disables the rows that don't apply. */ - private var integration by mutableStateOf(Integration.DRIVER) + /** Which integration is currently being demoed. Switching it disables rows that don't apply. */ + private var integration by mutableStateOf(IntegrationMode.DRIVER) /** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */ private var runTick by mutableStateOf(0) @@ -265,31 +323,19 @@ class SQLiteActivity : ComponentActivity() { SectionHeader("Configure it") - val openHelper = integration == Integration.OPEN_HELPER - val integrationSwitchColors = - SwitchDefaults.colors( - checkedTrackColor = SentryPink, - checkedBorderColor = SentryPink, - uncheckedTrackColor = SentryPurple, - uncheckedBorderColor = SentryPurple, - uncheckedThumbColor = Color.White, - ) val controlSwitchColors = SwitchDefaults.colors( checkedTrackColor = Color.Black, checkedBorderColor = Color.Black, ) - ToggleRow( - label = if (openHelper) "SentrySupportSQLiteOpenHelper" else "SentrySQLiteDriver", - checked = openHelper, - labelColor = if (openHelper) SentryPink else SentryPurple, - switchColors = integrationSwitchColors, - ) { - integration = if (it) Integration.OPEN_HELPER else Integration.DRIVER - // Switching integration starts a fresh comparison: clear the detail box and result. - sqlDetail = SQL_DETAIL_HINT - latestResult = "" - } + IntegrationModeSelector( + selected = integration, + onSelected = { + integration = it + sqlDetail = SQL_DETAIL_HINT + latestResult = "" + }, + ) ToggleRow( label = if (heavyWork) "Heavy app-level work" else "No app-level work", checked = heavyWork, @@ -313,12 +359,12 @@ class SQLiteActivity : ComponentActivity() { // integration's variant; a row that doesn't apply explains why via a toast (see // [DemoRowButton]). DEMO_ROWS.forEach { row -> - val variant = if (integration == Integration.DRIVER) row.driver else row.openHelper + val variant = row.variantFor(integration) DemoRowButton( label = row.label, color = integration.color, variant = variant, - disabledReason = "${row.label} doesn't use the ${integration.apiName}", + disabledReason = "${row.label} doesn't apply to the ${integration.apiName} stack", ) } @@ -330,12 +376,26 @@ class SQLiteActivity : ComponentActivity() { // Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from // the detail output. SectionHeader("Under the hood", topPadding = CONTROL_SECTION_GAP) + LaunchedEffect(Unit) { + while (!SampleDatabases.isWarmUpComplete()) { + warmUpErrors = SampleDatabases.warmUpErrors + delay(250) + } + warmUpErrors = SampleDatabases.warmUpErrors + } + if (warmUpErrors.isNotEmpty()) { + Text( + text = warmUpErrors, + style = MaterialTheme.typography.bodyMedium, + color = SentryRed, + ) + } // The latest run result (row counts, errors). Hidden until the first run. if (latestResult.isNotEmpty()) { Text( text = latestResult, style = MaterialTheme.typography.bodyMedium, - color = if (latestResult.contains("failed")) SentryRed else Color.Unspecified, + color = if (latestResult.looksLikeError()) SentryRed else Color.Unspecified, ) } DetailField("SQL run", sqlDetail, borderColor = detailOutline) @@ -361,12 +421,13 @@ class SQLiteActivity : ComponentActivity() { lifecycleScope.launch { dbOperationInFlight = true try { - latestResult = + val result = withContext(Dispatchers.IO) { runInTransaction(variant.transactionName, variant.op) { SqlStatements.execute(applicationContext, variant.demo, heavyWork) } } + latestResult = result } finally { dbOperationInFlight = false } @@ -385,9 +446,41 @@ class SQLiteActivity : ComponentActivity() { startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork)) } + @OptIn(ExperimentalMaterial3Api::class) + @androidx.compose.runtime.Composable + private fun IntegrationModeSelector( + selected: IntegrationMode, + onSelected: (IntegrationMode) -> Unit, + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + IntegrationMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + shape = + SegmentedButtonDefaults.itemShape(index = index, count = IntegrationMode.entries.size), + onClick = { onSelected(mode) }, + selected = selected == mode, + icon = {}, + colors = + SegmentedButtonDefaults.colors( + activeContainerColor = mode.color, + activeContentColor = Color.White, + ), + label = { Text(mode.segmentLabel, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + + Text( + text = selected.subtitle, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.padding(top = 6.dp), + ) + } + /** * A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the - * label inherits the default text color; the integration toggle passes its pink/purple instead. + * label inherits the default text color. */ @androidx.compose.runtime.Composable private fun ToggleRow( @@ -533,7 +626,11 @@ class SQLiteActivity : ComponentActivity() { try { val message = withContext(Dispatchers.IO) { resetDatabases() } latestResult = message + warmUpErrors = SampleDatabases.warmUpErrors sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + } catch (t: Throwable) { + Log.e(TAG, "Reset failed", t) + latestResult = "Reset failed: ${t.message ?: t.javaClass.simpleName}" } finally { this@SQLiteActivity.dbOperationInFlight = false this@SQLiteActivity.resetInProgress = false @@ -595,7 +692,8 @@ class SQLiteActivity : ComponentActivity() { result } catch (t: Throwable) { transaction.status = SpanStatus.INTERNAL_ERROR - "$transactionName failed: ${t.message}" + Log.e(TAG, "$transactionName failed", t) + "$transactionName failed: ${t.message ?: t.javaClass.simpleName}" } finally { transaction.finish() } @@ -604,11 +702,20 @@ class SQLiteActivity : ComponentActivity() { /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ private suspend fun resetDatabases(): String { val cleared = SampleDatabases.reset(applicationContext) - return "Dropped tables: cleared $cleared database file(s)." + SampleDatabases.awaitWarmUp() + return buildString { + append("Dropped tables: cleared $cleared database file(s).") + if (SampleDatabases.warmUpErrors.isNotEmpty()) { + append("\n\n") + append(SampleDatabases.warmUpErrors) + } + } } private companion object { + private const val TAG = "SQLiteActivity" + /** Demo SQL shorter than this won't visibly disable the reset button. */ private const val RESET_DISABLE_DEBOUNCE_MS = 300L @@ -619,3 +726,5 @@ class SQLiteActivity : ComponentActivity() { private fun newScreenTrace(): String = "${SentryId()}-${SpanId()}-1" } } + +private fun String.looksLikeError(): Boolean = contains("failed", ignoreCase = true) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt index 63f217fcfb..19b292cd91 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -1,12 +1,14 @@ package io.sentry.samples.android.sqlite import android.content.Context +import android.util.Log import androidx.room.Room import androidx.room3.Room as Room3 import androidx.sqlite.SQLiteConnection import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.SupportSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL import app.cash.sqldelight.driver.android.AndroidSqliteDriver @@ -18,6 +20,7 @@ import io.sentry.samples.android.sqlite.SampleDatabases.warmUp import io.sentry.sqlite.SentrySQLiteDriver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -42,18 +45,40 @@ import kotlinx.coroutines.sync.withLock */ object SampleDatabases { + private const val TAG = "SampleDatabases" + + /** Non-empty when one or more warm-up steps failed; shown on [SQLiteActivity]. */ + @Volatile + var warmUpErrors: String = "" + private set + + @Volatile private var warmUpComplete = false + @Volatile private var warmUpGeneration = 0 + @Volatile private var warmUpJob: Job? = null + + fun isWarmUpComplete(): Boolean = warmUpComplete + + /** Blocks until the in-flight [warmUp] job (if any) finishes. */ + suspend fun awaitWarmUp() { + warmUpJob?.join() + } + private val sqlAccess = Mutex() val driverDirectLock = Any() + val bridgeDirectLock = Any() val openHelperDirectLock = Any() /** Serializes demo SQL and [reset] so handles are never closed mid-statement. */ suspend fun withSqlAccess(block: suspend () -> T): T = sqlAccess.withLock { block() } @Volatile private var driverConnection: SQLiteConnection? = null + @Volatile private var bridgeConnection: SQLiteConnection? = null @Volatile private var driverRoom2Db: SampleRoom2Database? = null + @Volatile private var bridgeRoom2Db: SampleRoom2Database? = null @Volatile private var driverRoom3Db: SampleRoom3Database? = null @Volatile private var directHelper: SupportSQLiteOpenHelper? = null + @Volatile private var bridgeDirectHelper: SupportSQLiteOpenHelper? = null @Volatile private var openHelperRoomDb: SampleRoom2Database? = null @Volatile private var sqlDelightDriver: AndroidSqliteDriver? = null @@ -68,6 +93,45 @@ object SampleDatabases { } } + /** + * The Room 2.7+ duplicate-span scenario: a Sentry-wrapped open helper bridged to + * [SupportSQLiteDriver], then passed to [SentrySQLiteDriver.create] (which no-ops on the bridge). + */ + fun bridgeConnection(context: Context): SQLiteConnection = + synchronized(bridgeDirectLock) { + bridgeConnection + ?: run { + // SupportSQLiteDriver.open() requires fileName to match the helper's databaseName(); + // use the absolute path Room and the direct driver path both pass to open(). + val dbPath = databaseFile(context, "bridge_direct.db") + SentrySQLiteDriver.create(SupportSQLiteDriver(buildBridgeDirectHelper(context, dbPath))) + .open(dbPath) + .also { + it.execSQL(SqlStatements.CREATE_SONG) + bridgeConnection = it + } + } + } + + fun bridgeRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + bridgeRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "bridge_room2.db", + ) + .setDriver( + SentrySQLiteDriver.create( + SupportSQLiteDriver(buildBridgeRoom2Helper(context.applicationContext)) + ) + ) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { bridgeRoom2Db = it } + } + fun driverRoom2Db(context: Context): SampleRoom2Database = synchronized(this) { driverRoom2Db @@ -133,10 +197,50 @@ object SampleDatabases { .also { sqlDelightDriver = it } } - private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper { + private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper = + buildSentryHelper(context, "openhelper_direct.db").also { directHelper = it } + + private fun buildBridgeDirectHelper(context: Context, dbPath: String): SupportSQLiteOpenHelper = + buildSentryHelper(context, dbPath).also { bridgeDirectHelper = it } + + /** + * Open helper for the Bridge + Room 2 stack. Must not create tables in [onCreate] — Room owns the + * schema when [setDriver] is used. Room also passes [SupportSQLiteOpenHelper.databaseName] (the + * short name below), not an absolute path, to [SupportSQLiteDriver.open]. + * + * The callback version must be 1 (FrameworkSQLiteOpenHelper rejects < 1). That sets `PRAGMA + * user_version = 1` before Room opens, so Room would skip [onCreate] and validate the empty file + * as pre-packaged → "invalid schema". [onOpen] clears user_version back to 0 until + * [ROOM_MASTER_TABLE] exists. + */ + private fun buildBridgeRoom2Helper(context: Context): SupportSQLiteOpenHelper { val configuration = SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) - .name("openhelper_direct.db") + .name("bridge_room2.db") + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + + override fun onOpen(db: SupportSQLiteDatabase) { + if (!db.hasRoomMasterTable()) { + db.execSQL("PRAGMA user_version = 0") + } + } + } + ) + .build() + return SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + + private fun buildSentryHelper(context: Context, dbName: String): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name(dbName) .callback( object : SupportSQLiteOpenHelper.Callback(1) { override fun onCreate(db: SupportSQLiteDatabase) { @@ -156,22 +260,50 @@ object SampleDatabases { /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ fun warmUp(context: Context) { val appContext = context.applicationContext + val generation = ++warmUpGeneration + warmUpComplete = false + warmUpErrors = "" // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. - CoroutineScope(Dispatchers.IO).launch { - runCatching { driverConnection(appContext) } - // primeWriter() + count() opens both Room pool connections (writer + reader), so the first - // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its - // transaction. - runCatching { driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { directHelper(appContext).writableDatabase } - runCatching { openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { - SampleSQLDelightDatabase(sqlDelightDriver(appContext)) - .songQueries - .countSongs() - .executeAsOne() + warmUpJob = + CoroutineScope(Dispatchers.IO).launch { + val failures = mutableListOf() + runWarmUpStep("driver direct", failures) { driverConnection(appContext) } + runWarmUpStep("bridge direct", failures) { bridgeConnection(appContext) } + // primeWriter() + count() opens both Room pool connections (writer + reader), so the first + // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its + // transaction. + runWarmUpStep("driver Room 2", failures) { + driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("bridge Room 2", failures) { + bridgeRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("driver Room 3", failures) { + driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("open helper direct", failures) { directHelper(appContext).writableDatabase } + runWarmUpStep("open helper Room", failures) { + openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("SQLDelight", failures) { + SampleSQLDelightDatabase(sqlDelightDriver(appContext)) + .songQueries + .countSongs() + .executeAsOne() + } + if (generation == warmUpGeneration) { + warmUpErrors = failures.joinToString("\n") { "Warm-up failed: $it" } + warmUpComplete = true + } } + } + + private inline fun runWarmUpStep(step: String, failures: MutableList, block: () -> Unit) { + try { + block() + } catch (t: Throwable) { + Log.e(TAG, "Warm-up failed: $step", t) + failures.add("$step: ${t.message ?: t.javaClass.simpleName}") } } @@ -185,7 +317,9 @@ object SampleDatabases { val names = listOf( "driver_direct.db", + "bridge_direct.db", "driver_room2.db", + "bridge_room2.db", "driver_room3.db", "openhelper_direct.db", "openhelper_room.db", @@ -201,6 +335,12 @@ object SampleDatabases { driverConnection?.close() driverConnection = null } + synchronized(bridgeDirectLock) { + bridgeConnection?.close() + bridgeConnection = null + bridgeDirectHelper?.close() + bridgeDirectHelper = null + } synchronized(openHelperDirectLock) { directHelper?.close() directHelper = null @@ -208,6 +348,8 @@ object SampleDatabases { synchronized(this) { driverRoom2Db?.close() driverRoom2Db = null + bridgeRoom2Db?.close() + bridgeRoom2Db = null driverRoom3Db?.close() driverRoom3Db = null openHelperRoomDb?.close() @@ -219,4 +361,11 @@ object SampleDatabases { private fun databaseFile(context: Context, name: String): String = context.applicationContext.getDatabasePath(name).also { it.parentFile?.mkdirs() }.absolutePath + + private fun SupportSQLiteDatabase.hasRoomMasterTable(): Boolean = + query("SELECT 1 FROM sqlite_master WHERE name = '$ROOM_MASTER_TABLE' LIMIT 1").use { + it.moveToFirst() + } } + +private const val ROOM_MASTER_TABLE = "room_master_table" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt index 543f116929..a3646414bb 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -18,6 +18,8 @@ enum class SqlDemo { DRIVER_DIRECT, DRIVER_ROOM2, DRIVER_ROOM3, + BRIDGE_DIRECT, + BRIDGE_ROOM2, OPENHELPER_DIRECT, OPENHELPER_ROOM, OPENHELPER_SQLDELIGHT, @@ -64,6 +66,8 @@ object SqlStatements { SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) + SqlDemo.BRIDGE_DIRECT -> bridgeDirect(context, heavy) + SqlDemo.BRIDGE_ROOM2 -> bridgeWithRoom2(context, heavy) SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) @@ -135,6 +139,37 @@ object SqlStatements { return "$label: ${dao.count()} rows." } + // --- 1b. SupportSQLiteDriver bridge (helper + driver both wrapped; SDK skips driver wrap) -- + + private fun bridgeDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.bridgeDirectLock) { + val connection = SampleDatabases.bridgeConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + connection.prepare(insertSongsBatch(HEAVY_ROW_COUNT)).use { statement -> + var param = 1 + repeat(HEAVY_ROW_COUNT) { row -> + statement.bindText(param++, "song $row") + statement.bindText(param++, "artist $row") + } + statement.step() + } + + connection.prepare(SELECT_SONGS).use { statement -> + while (statement.step()) { + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + appWork(row) + } + } + } + "Bridge (Direct): ${count(connection)} rows." + } + + private suspend fun bridgeWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.bridgeRoom2Db(context).songDao(), "Bridge (Room 2)", heavy) + // --- 2b. SentrySQLiteDriver, used through Room 3.0+ (androidx.room3) ----------------------- private suspend fun driverWithRoom3(context: Context, heavy: Boolean): String { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt index b32811e8c9..3cc6d394da 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt @@ -3,6 +3,7 @@ package io.sentry.samples.android.sqlite import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.getValue @@ -48,7 +49,8 @@ class UiLoadActivity : ComponentActivity() { withContext(Dispatchers.IO) { SqlStatements.execute(applicationContext, id, heavy) } "$result\n\nRan under the auto ui.load transaction." } catch (t: Throwable) { - "Load failed: ${t.message}" + Log.e(TAG, "Load failed", t) + "Load failed: ${t.message ?: t.javaClass.simpleName}" } finally { // Close the TTFD window so the ui.load transaction finishes with the db spans attached. Sentry.reportFullyDisplayed() @@ -57,6 +59,7 @@ class UiLoadActivity : ComponentActivity() { } companion object { + private const val TAG = "UiLoadActivity" private const val EXTRA_DEMO_ID = "demo_id" private const val EXTRA_HEAVY = "heavy"