diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c286dc794..710a794218 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,16 +5,19 @@ androidxNavigation = "2.4.2" androidxTestCore = "1.7.0" androidxCompose = "1.6.3" asyncProfiler = "4.2" +camerax = "1.4.0" composeCompiler = "1.5.14" coroutines = "1.6.1" espresso = "3.7.0" feign = "11.6" +gummyBears = "0.12.0" jacoco = "0.8.7" jackson = "2.18.3" jetbrainsCompose = "1.6.11" kotlin = "2.2.0" kotlinSpring7 = "2.2.0" kotlin-compatible-version = "1.9" +ksp = "2.2.0-2.0.2" ktorClient = "3.0.0" logback = "1.2.9" log4j2 = "2.20.0" @@ -22,6 +25,7 @@ nopen = "1.0.1" # see https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html#kotlin-compatibility # see https://developer.android.com/jetpack/androidx/releases/compose-kotlin okhttp = "4.9.2" +openfeature = "1.18.2" otel = "1.60.1" otelInstrumentation = "2.26.0" otelInstrumentationAlpha = "2.26.0-alpha" @@ -29,18 +33,21 @@ otelInstrumentationAlpha = "2.26.0-alpha" otelSemanticConventions = "1.40.0" otelSemanticConventionsAlpha = "1.40.0-alpha" retrofit = "2.9.0" +room2 = "2.8.4" +room3 = "3.0.0-alpha06" +sqlite = "2.6.2" +sqliteAlpha = "2.7.0-alpha06" # Required by Room3 3.0.0-alpha* slf4j = "1.7.30" +spotless = "8.4.0" springboot2 = "2.7.18" springboot3 = "3.5.0" springboot4 = "4.0.0" +sqldelight = "2.3.2" + # Android targetSdk = "36" compileSdk = "36" minSdk = "21" -spotless = "8.4.0" -gummyBears = "0.12.0" -camerax = "1.4.0" -openfeature = "1.18.2" [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -50,6 +57,7 @@ kotlin-jvm-spring7 = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinSpr kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } buildconfig = { id = "com.github.gmazzo.buildconfig", version = "5.6.5" } dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } dokka-javadoc = { id = "org.jetbrains.dokka-javadoc", version = "2.0.0" } @@ -64,6 +72,7 @@ vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.3 springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } gretty = { id = "org.gretty", version = "4.0.0" } animalsniffer = { id = "ru.vyarus.animalsniffer", version = "2.0.1" } sentry = { id = "io.sentry.android.gradle", version = "6.6.0"} @@ -94,7 +103,14 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } -androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.6.2" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" } +androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } +androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteAlpha" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteAlpha" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } @@ -207,6 +223,7 @@ springboot4-starter-jdbc = { module = "org.springframework.boot:spring-boot-star springboot4-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot4" } springboot4-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot4" } springboot4-starter-kafka = { module = "org.springframework.boot:spring-boot-starter-kafka", version.ref = "springboot4" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" } # Animalsniffer signature @@ -249,3 +266,7 @@ msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } + +[bundles] +androidx-room2 = ["androidx-room-runtime", "androidx-room-ktx"] +androidx-sqlite-drivers = ["androidx-sqlite-bundled", "androidx-sqlite-framework"] diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md new file mode 100644 index 0000000000..b2efa441c3 --- /dev/null +++ b/sentry-samples/sentry-samples-android/README.md @@ -0,0 +1,27 @@ +# Sentry Sample Android App + +Sample application demonstrating how to use the Sentry Android SDK, including core functionality (error reporting, tracing, session replay, profiling) and integrations (Compose, OkHttp, SQLDelight, etc.). + +## How to run it? + +Install the app on your device or emulator: + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug +``` + +or simply open the project in Android Studio and run the `sentry-samples-android` configuration. + +## Viewing events locally + +Debug builds enable SDK debug logging, so captured envelopes are printed to logcat (tag `Sentry`): + +``` +adb logcat -s Sentry +``` + +## Viewing events on Sentry UI + +By default, events appear under the [sentry-sdk test project](https://sentry-sdks.sentry.io/issues/?project=5428559). +To redirect them to your own project, replace the test DSN (i.e., the `io.sentry.dsn` `meta-data` value) +in `src/main/AndroidManifest.xml` with your own. diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index ed8cea2566..e7122028a9 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -7,6 +7,8 @@ plugins { id("com.android.application") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.sqldelight) } android { @@ -15,7 +17,8 @@ android { defaultConfig { applicationId = "io.sentry.samples.android" - minSdk = libs.versions.minSdk.get().toInt() + // androidx.sqlite 2.6+ require minSdk 23; the Sentry SDK still supports 21. + minSdk = 23 targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 2 versionName = project.version.toString() @@ -90,7 +93,13 @@ android { } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + // Java 11 b/c androidx.room3 requires it. + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 } androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) @@ -116,6 +125,17 @@ android { @Suppress("UnstableApiUsage") packagingOptions { jniLibs { useLegacyPackaging = true } } } +sqldelight { + databases { + create("SampleSQLDelightDatabase") { + packageName.set("io.sentry.samples.android.sqlite") + // Keep .sq files next to the hand-written Kotlin (src/main/java/.../sqlite) instead of the + // default src/main/sqldelight source root. + srcDirs("src/main/java") + } + } +} + dependencies { implementation( kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) @@ -123,6 +143,7 @@ dependencies { implementation(projects.sentryAndroid) implementation(projects.sentryAndroidFragment) + implementation(projects.sentryAndroidSqlite) implementation(projects.sentryAndroidTimber) implementation(projects.sentryCompose) implementation(projects.sentryKotlinExtensions) @@ -148,17 +169,24 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.recyclerview) implementation(libs.androidx.browser) + implementation(libs.androidx.room3.runtime) + implementation(libs.bundles.androidx.room2) + implementation(libs.bundles.androidx.sqlite.drivers) + implementation(libs.camerax.camera2) + implementation(libs.camerax.core) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) implementation(libs.coil.compose) implementation(libs.kotlinx.coroutines.android) implementation(libs.lottie.compose) implementation(libs.retrofit) implementation(libs.retrofit.gson) implementation(libs.sentry.native.ndk) + implementation(libs.sqldelight.android.driver) implementation(libs.timber) - implementation(libs.camerax.core) - implementation(libs.camerax.camera2) - implementation(libs.camerax.lifecycle) - implementation(libs.camerax.view) + + ksp(libs.androidx.room.compiler) + ksp(libs.androidx.room3.compiler) debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index e5b5ed2250..23ef7e62c7 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -92,6 +92,14 @@ android:name=".TriggerHttpRequestActivity" android:exported="false" /> + + + + ) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() +} + +@Database(entities = [SongEntity::class], version = 1, exportSchema = false) +abstract class SampleRoom2Database : RoomDatabase() { + + abstract fun songDao(): SongDao +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt new file mode 100644 index 0000000000..145e12d389 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt @@ -0,0 +1,42 @@ +package io.sentry.samples.android.sqlite + +import androidx.room3.Dao +import androidx.room3.Database +import androidx.room3.Entity +import androidx.room3.Insert +import androidx.room3.PrimaryKey +import androidx.room3.Query +import androidx.room3.RoomDatabase + +@Entity(tableName = "song") +data class SongEntity3( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val title: String, + val artist: String, +) + +@Dao +interface SongDao3 { + + @Insert suspend fun insert(song: SongEntity3) + + /** Batch insert: Room runs all rows in a single transaction, reusing one compiled statement. */ + @Insert suspend fun insertAll(songs: List) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() +} + +@Database(entities = [SongEntity3::class], version = 1, exportSchema = false) +abstract class SampleRoom3Database : RoomDatabase() { + + abstract fun songDao(): SongDao3 +} 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 new file mode 100644 index 0000000000..f0492c0b47 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -0,0 +1,617 @@ +package io.sentry.samples.android.sqlite + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.keyframes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import io.sentry.SpanId +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.SentryId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val SentryPink = Color(0xFFC85B9C) +private val SentryPurple = Color(0xFF7B52FB) +private val SentryRed = Color(0xFFF55459) + +/** Intro text, surfaced via the "?" tooltip next to the "Run it" header. */ +private const val INSTRUCTIONS = + "Tap a button to execute a SQL statement in its own transaction; long press to run it in a ui.load transaction." + +/** Start state of the "SQL run" box. */ +private const val SQL_DETAIL_HINT = "Tap a button above to see the SQL it runs…" + +private val TOGGLE_SECTION_GAP = 24.dp + +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"), +} + +/** + * How one demo button behaves for a given integration: which [SqlStatements] work it runs ([demo]), + * the name/op of the manual transaction a tap wraps it in, and the SQL summary shown in the detail + * panel ([displayInfo]). + */ +private class DemoVariant( + val demo: SqlDemo, + val transactionName: String, + val op: String, + val displayInfo: DisplayInfo, +) + +/** + * 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). + */ +private class DemoRow(val label: String, val driver: DemoVariant?, val openHelper: DemoVariant?) + +// The demo buttons, top to bottom, paired with each integration's variant. Pure data — the actual +// SQL lives in SqlStatements, dispatched by id. +private val DEMO_ROWS = + listOf( + DemoRow( + label = "Direct (no library)", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_DIRECT, + transactionName = "SentrySQLiteDriver — Direct", + op = "db.sql.driver-direct", + displayInfo = DRIVER_DIRECT, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_DIRECT, + transactionName = "SentrySupportSQLiteOpenHelper — Direct", + op = "db.sql.openhelper-direct", + displayInfo = OPENHELPER_DIRECT, + ), + ), + DemoRow( + label = "Room 2", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM2, + transactionName = "SentrySQLiteDriver — Room 2", + op = "db.sql.driver-room2", + displayInfo = DRIVER_ROOM2, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_ROOM, + transactionName = "SentrySupportSQLiteOpenHelper — Room", + op = "db.sql.openhelper-room", + displayInfo = OPENHELPER_ROOM, + ), + ), + DemoRow( + label = "Room 3", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM3, + transactionName = "SentrySQLiteDriver — Room 3", + op = "db.sql.driver-room3", + displayInfo = DRIVER_ROOM3, + ), + openHelper = null, // Room 3 only runs on the SQLiteDriver path. + ), + DemoRow( + label = "SQLDelight", + driver = null, // SQLDelight's AndroidSqliteDriver is built on SupportSQLiteOpenHelper. + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_SQLDELIGHT, + transactionName = "SentrySupportSQLiteOpenHelper — SQLDelight", + op = "db.sql.openhelper-sqldelight", + displayInfo = OPENHELPER_SQLDELIGHT, + ), + ), + ) + +/** + * Activity that lets us exercise our two `sentry-android-sqlite` integrations + * ([SentrySQLiteDriver][io.sentry.sqlite.SentrySQLiteDriver] and + * [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper]), both + * directly and via Room or SQLDelight. + * + * Example SQL statements are deliberately identical across integrations so we can identify + * similarities and differences in their transaction / span support. + */ +class SQLiteActivity : ComponentActivity() { + + private var latestResult by mutableStateOf("") + private var sqlDetail by mutableStateOf(SQL_DETAIL_HINT) + private var heavyWork by mutableStateOf(false) + + /** + * When enabled, every per-button transaction in one screen visit continues [screenTraceHeader], + * so they all share a trace ("session"-like). When disabled (the default), each tap is the root + * of its own trace, which renders as a standalone waterfall scaled to that one transaction — + * easier to read how time is allocated among its spans. + */ + 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) + + /** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */ + private var runTick by mutableStateOf(0) + + /** True while a demo or reset is running SQL on a background thread. */ + private var dbOperationInFlight by mutableStateOf(false) + + /** True for the duration of a reset; disables the reset button immediately (no debounce). */ + private var resetInProgress by mutableStateOf(false) + + /** + * The shared trace used when [shareScreenTrace] is enabled: one trace per visit to this screen. + * onResume() generates a fresh one each time the screen is (re)entered. + */ + private var screenTraceHeader = newScreenTrace() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + Surface { + Column( + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val screenHeightDp = LocalConfiguration.current.screenHeightDp + // A small gap below the screen title that grows with screen height and collapses to 0 + // on short screens, so the title isn't crowded against "Configure it" on tall devices. + val titleGap = + (((((screenHeightDp / 4) - 48) / 3).coerceAtLeast(0).dp + TOGGLE_SECTION_GAP) / 2 - + SECTION_HEADER_HEIGHT) + .coerceAtLeast(0.dp) + + // Pulse the "Under the hood" outline in the integration color whenever a tap runs SQL. + val shimmer = remember { Animatable(0f) } + LaunchedEffect(runTick) { + if (runTick == 0) return@LaunchedEffect + shimmer.animateTo( + targetValue = 0f, + animationSpec = + keyframes { + durationMillis = 900 + 0f at 0 + 1f at 200 + 0.4f at 450 + 1f at 650 + 0f at 900 + }, + ) + } + + val detailOutline = + lerp(MaterialTheme.colorScheme.outline, integration.color, shimmer.value) + + Text(text = "SQLite Instrumentation", style = MaterialTheme.typography.headlineSmall) + + Spacer(Modifier.height(titleGap)) + + 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 = "" + } + ToggleRow( + label = if (heavyWork) "Heavy app-level work" else "No app-level work", + checked = heavyWork, + switchColors = controlSwitchColors, + ) { + heavyWork = it + } + ToggleRow( + label = + if (shareScreenTrace) "Single trace for all button clicks" + else "Separate trace per button click", + checked = shareScreenTrace, + switchColors = controlSwitchColors, + ) { + shareScreenTrace = it + } + + SectionHeader("Run it", topPadding = CONTROL_SECTION_GAP) { HelpTooltip() } + + // One consolidated list of demo buttons. Each row dispatches to the selected + // 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 + DemoRowButton( + label = row.label, + color = integration.color, + variant = variant, + disabledReason = "${row.label} doesn't use the ${integration.apiName}", + ) + } + + ResetButton( + dbOperationInFlight = dbOperationInFlight, + resetInProgress = resetInProgress, + ) + + // Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from + // the detail output. + SectionHeader("Under the hood", topPadding = CONTROL_SECTION_GAP) + // 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, + ) + } + DetailField("SQL run", sqlDetail, borderColor = detailOutline) + } + } + } + } + } + + override fun onResume() { + super.onResume() + // Start a new trace each time the user (re)enters the screen, so each visit is its own session. + screenTraceHeader = newScreenTrace() + } + + /** Run the variant's SQL statement inside a manual, scope-bound transaction. */ + private fun onTap(variant: DemoVariant) { + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + runTick++ // shimmer the detail box outline in the integration color + + lifecycleScope.launch { + dbOperationInFlight = true + try { + latestResult = + withContext(Dispatchers.IO) { + runInTransaction(variant.transactionName, variant.op) { + SqlStatements.execute(applicationContext, variant.demo, heavyWork) + } + } + } finally { + dbOperationInFlight = false + } + } + } + + /** + * Run the variant's SQL statement in [UiLoadActivity] with no manual transaction, so its auto + * `ui.load` transaction owns the spans. + */ + private fun onLongPress(variant: DemoVariant) { + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + latestResult = "Opened the auto-load screen — its ui.load transaction owns the db spans." + startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork)) + } + + /** + * 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. + */ + @androidx.compose.runtime.Composable + private fun ToggleRow( + label: String, + checked: Boolean, + modifier: Modifier = Modifier, + labelColor: Color = Color.Unspecified, + switchColors: SwitchColors = SwitchDefaults.colors(), + onCheckedChange: (Boolean) -> Unit, + ) { + // Constrain the row height: a Switch otherwise reserves ~48dp, leaving a large gap between the + // toggles. 32dp keeps them about one line of text apart. + Row(modifier = modifier.height(32.dp), verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = switchColors, + modifier = Modifier.scale(0.75f), + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = labelColor, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + + @androidx.compose.runtime.Composable + private fun SectionHeader( + title: String, + topPadding: Dp = 8.dp, + trailing: (@androidx.compose.runtime.Composable () -> Unit)? = null, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(top = topPadding)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + trailing?.invoke() + } + HorizontalDivider(thickness = 1.dp, modifier = Modifier.padding(top = 4.dp)) + } + } + + /** + * A circled "?" next to the "Run it" header. Tapping it briefly shows the [INSTRUCTIONS] in a + * tooltip that auto-dismisses after a few seconds. + */ + @OptIn(ExperimentalMaterial3Api::class) + @androidx.compose.runtime.Composable + private fun HelpTooltip() { + val tooltipState = rememberTooltipState(isPersistent = true) + val scope = rememberCoroutineScope() + LaunchedEffect(tooltipState.isVisible) { + if (tooltipState.isVisible) { + delay(4000) + tooltipState.dismiss() + } + } + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(INSTRUCTIONS) } }, + state = tooltipState, + ) { + Icon( + imageVector = Icons.Outlined.HelpOutline, + contentDescription = "What do the buttons do?", + tint = Color.Gray, + modifier = + Modifier.padding(start = 8.dp).size(20.dp).clickable { + scope.launch { tooltipState.show() } + }, + ) + } + } + + /** + * A filled button that runs [variant] on tap (manual transaction) or long-press (ui.load). It's a + * [Surface] rather than a [Button] because Material3's Button has no long-press hook; the + * [combinedClickable] modifier gives us both. + * + * A null [variant] means the row doesn't apply to the selected integration: the button renders + * dimmed and, when clicked, explains why via a toast ([disabledReason]) instead of running. + */ + @OptIn(ExperimentalFoundationApi::class) + @androidx.compose.runtime.Composable + private fun DemoRowButton( + label: String, + color: Color, + variant: DemoVariant?, + disabledReason: String, + ) { + val context = LocalContext.current + val enabled = variant != null + val explain = { Toast.makeText(context, disabledReason, Toast.LENGTH_SHORT).show() } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = ButtonDefaults.shape, + color = if (enabled) color else color.copy(alpha = 0.26f), + contentColor = Color.White, + ) { + Box( + modifier = + Modifier.combinedClickable( + onClick = { if (variant != null) onTap(variant) else explain() }, + onLongClick = { if (variant != null) onLongPress(variant) else explain() }, + ) + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, style = MaterialTheme.typography.labelLarge) + } + } + } + + @androidx.compose.runtime.Composable + private fun ResetButton(dbOperationInFlight: Boolean, resetInProgress: Boolean) { + // Debounce demo-driven disablement so fast taps don't flicker the button; reset disables + // immediately via [resetInProgress]. [dbOperationInFlight] still guards [onClick] either way. + var enabled by remember { mutableStateOf(true) } + LaunchedEffect(dbOperationInFlight, resetInProgress) { + when { + resetInProgress -> enabled = false + dbOperationInFlight -> { + delay(RESET_DISABLE_DEBOUNCE_MS) + enabled = false + } + else -> enabled = true + } + } + + Button( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = Color.Gray, contentColor = Color.White), + onClick = { + if (dbOperationInFlight) return@Button + lifecycleScope.launch { + this@SQLiteActivity.resetInProgress = true + this@SQLiteActivity.dbOperationInFlight = true + try { + val message = withContext(Dispatchers.IO) { resetDatabases() } + latestResult = message + sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + } finally { + this@SQLiteActivity.dbOperationInFlight = false + this@SQLiteActivity.resetInProgress = false + } + } + }, + ) { + Text("Drop all tables (reset)") + } + } + + @androidx.compose.runtime.Composable + private fun DetailField(label: String, value: String, borderColor: Color) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + textStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp), + // The border color is driven by the shimmer animation so the box pulses on each SQL run. + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = borderColor, + unfocusedBorderColor = borderColor, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + + /** + * Runs [block] inside a scope-bound transaction and returns the result. When [shareScreenTrace] + * is enabled, the transaction continues this screen's trace so all demos in one visit share a + * trace; otherwise it starts its own trace (1 transaction = 1 trace). + */ + private suspend fun runInTransaction( + transactionName: String, + op: String, + block: suspend () -> String, + ): String { + // Continuing the screen trace keeps the shared trace id but mints a fresh span id for this + // transaction; the standalone path (and the continueTrace fallback when tracing is disabled) + // gives the transaction its own trace. + val context = + if (shareScreenTrace) { + Sentry.continueTrace(screenTraceHeader, null)?.apply { + name = transactionName + operation = op + } ?: TransactionContext(transactionName, op) + } else { + TransactionContext(transactionName, op) + } + + val options = TransactionOptions().apply { isBindToScope = true } + val transaction = Sentry.startTransaction(context, options) + + return try { + val result = block() + transaction.status = SpanStatus.OK + result + } catch (t: Throwable) { + transaction.status = SpanStatus.INTERNAL_ERROR + "$transactionName failed: ${t.message}" + } finally { + transaction.finish() + } + } + + /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ + private fun resetDatabases(): String { + val cleared = SampleDatabases.reset(applicationContext) + return "Dropped tables: cleared $cleared database file(s)." + } + + private companion object { + + /** Demo SQL shorter than this won't visibly disable the reset button. */ + private const val RESET_DISABLE_DEBOUNCE_MS = 300L + + /** + * Builds a fresh sentry-trace header ("--") representing this screen + * visit's trace. The trailing "-1" marks it sampled so the whole session is kept. + */ + private fun newScreenTrace(): String = "${SentryId()}-${SpanId()}-1" + } +} 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 new file mode 100644 index 0000000000..8479a5c1eb --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -0,0 +1,215 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +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.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper +import io.sentry.samples.android.sqlite.SampleDatabases.driverDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.openHelperDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.reset +import io.sentry.samples.android.sqlite.SampleDatabases.warmUp +import io.sentry.sqlite.SentrySQLiteDriver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Process-lifetime holder for the demo databases used by [SQLiteActivity]. + * + * Real apps open a database once (commonly a DI singleton) and keep it open for the process, so a + * screen that touches the DB almost always finds it already "warm". We model that here: [warmUp] is + * called from `MyApplication` at launch, off the main thread, so the one-time open + Room + * connection-pool bootstrap happens with no active transaction — those `db.sql.query` spans have + * nothing to attach to and are dropped. Every screen afterward reuses the warm handle and records + * only its statements of interest. + * + * Handles are held for the whole process: Android has no reliable "app closed" callback, and the OS + * reclaims the connections on process death, so we never close them except via [reset] (the "Drop + * all tables" button), which closes, deletes the files, and re-warms. + * + * The two "direct" handles wrap a single raw connection that isn't safe for concurrent use, so + * callers serialize their whole unit of work via [driverDirectLock] / [openHelperDirectLock]. Room + * and SQLDelight manage their own connection pools and don't need one. + */ +object SampleDatabases { + + val driverDirectLock = Any() + val openHelperDirectLock = Any() + + @Volatile private var driverConnection: SQLiteConnection? = null + @Volatile private var driverRoom2Db: SampleRoom2Database? = null + @Volatile private var driverRoom3Db: SampleRoom3Database? = null + @Volatile private var directHelper: SupportSQLiteOpenHelper? = null + @Volatile private var openHelperRoomDb: SampleRoom2Database? = null + @Volatile private var sqlDelightDriver: AndroidSqliteDriver? = null + + fun driverConnection(context: Context): SQLiteConnection = + synchronized(driverDirectLock) { + driverConnection + ?: SentrySQLiteDriver.create(BundledSQLiteDriver()) + .open(databaseFile(context, "driver_direct.db")) + .also { + it.execSQL(SqlStatements.CREATE_SONG) // one-time table setup, at open + driverConnection = it + } + } + + fun driverRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + driverRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "driver_room2.db", + ) + .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { driverRoom2Db = it } + } + + fun driverRoom3Db(context: Context): SampleRoom3Database = + synchronized(this) { + driverRoom3Db + ?: Room3.databaseBuilder(context.applicationContext, "driver_room3.db") + .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + .also { driverRoom3Db = it } + } + + fun directHelper(context: Context): SupportSQLiteOpenHelper = + synchronized(openHelperDirectLock) { + directHelper ?: buildDirectHelper(context).also { directHelper = it } + } + + fun openHelperRoomDb(context: Context): SampleRoom2Database = + synchronized(this) { + openHelperRoomDb + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "openhelper_room.db", + ) + .openHelperFactory { configuration -> + SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + .fallbackToDestructiveMigration(true) + .build() + .also { openHelperRoomDb = it } + } + + fun sqlDelightDriver(context: Context): AndroidSqliteDriver = + synchronized(this) { + sqlDelightDriver + ?: AndroidSqliteDriver( + schema = SampleSQLDelightDatabase.Schema, + context = context.applicationContext, + name = "openhelper_sqldelight.db", + factory = + SupportSQLiteOpenHelper.Factory { configuration -> + SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + }, + ) + .also { sqlDelightDriver = it } + } + + private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name("openhelper_direct.db") + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL(SqlStatements.CREATE_SONG) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + } + ) + .build() + return SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + + /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ + fun warmUp(context: Context) { + val appContext = context.applicationContext + // 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() + } + } + } + + /** + * Closes the open handles, deletes every demo database file, then re-warms. Returns the number of + * files cleared. + */ + fun reset(context: Context): Int { + closeAll() + val appContext = context.applicationContext + val names = + listOf( + "driver_direct.db", + "driver_room2.db", + "driver_room3.db", + "openhelper_direct.db", + "openhelper_room.db", + "openhelper_sqldelight.db", + ) + val cleared = names.count { appContext.deleteDatabase(it) } + warmUp(appContext) + return cleared + } + + private fun closeAll() { + synchronized(driverDirectLock) { + driverConnection?.close() + driverConnection = null + } + synchronized(openHelperDirectLock) { + directHelper?.close() + directHelper = null + } + synchronized(this) { + driverRoom2Db?.close() + driverRoom2Db = null + driverRoom3Db?.close() + driverRoom3Db = null + openHelperRoomDb?.close() + openHelperRoomDb = null + sqlDelightDriver?.close() + sqlDelightDriver = null + } + } + + private fun databaseFile(context: Context, name: String): String = + context.applicationContext.getDatabasePath(name).also { it.parentFile?.mkdirs() }.absolutePath +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq new file mode 100644 index 0000000000..345e55a358 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq @@ -0,0 +1,17 @@ +CREATE TABLE song ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + artist TEXT NOT NULL +); + +insertSong: +INSERT INTO song(title, artist) +VALUES (?, ?); + +selectAll: +SELECT * +FROM song; + +countSongs: +SELECT count(*) +FROM song; 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 new file mode 100644 index 0000000000..61dd2c732a --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -0,0 +1,224 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Rows inserted (and then consumed + processed) per demo when "heavy application-level work" is + * enabled. + */ +private const val HEAVY_ROW_COUNT = 50 + +/** + * Identifies a single SQLite demo: one of the two integrations crossed with the way it's used + * (raw/direct, Room, or SQLDelight). Used to dispatch the same SQL from both trace styles. + */ +enum class SqlDemo { + DRIVER_DIRECT, + DRIVER_ROOM2, + DRIVER_ROOM3, + OPENHELPER_DIRECT, + OPENHELPER_ROOM, + OPENHELPER_SQLDELIGHT, +} + +/** + * Executable SQL and demo runners for the SQLite sample screens. The human-readable "SQL run" + * summaries shown in the UI live in the per-demo [DisplayInfo] constants; keep those in lockstep + * with the statements here. + * + * The actual SQL each demo runs is kept separate from how its trace is created so the two screens + * can share it: + * - [SQLiteActivity]: Wraps [execute] in a manual `Sentry.startTransaction(…)`. + * - [UiLoadActivity]: Calls the same [execute] with no manual transaction, so the screen's auto + * `ui.load` transaction owns the resulting `db.sql.query` spans. + * + * All demos read the shared, already-warm handles from [SampleDatabases] and return a short status + * line. [heavy] mirrors the screen's "heavy app-level work" toggle. When enabled, each demo also + * batch inserts [HEAVY_ROW_COUNT] rows and consumes them with per-row [appWork]. + */ +object SqlStatements { + + const val CREATE_SONG = + "CREATE TABLE IF NOT EXISTS song(id INTEGER PRIMARY KEY, title TEXT, artist TEXT)" + const val INSERT_SONG = "INSERT INTO song(title, artist) VALUES (?, ?)" + const val SELECT_SONGS = "SELECT id, title, artist FROM song" + const val COUNT_SONGS = "SELECT count(*) FROM song" + + /** + * A single multi-row INSERT for [rowCount] songs, bound with [batchSongArgs]. One statement <> + * one round-trip, which is the realistic way to add a known batch of rows, rather than a loop of + * [rowCount] single-row inserts. + */ + fun insertSongsBatch(rowCount: Int): String = + "INSERT INTO song(title, artist) VALUES " + List(rowCount) { "(?, ?)" }.joinToString(", ") + + /** Flattened title/artist bind args for [insertSongsBatch]: "song 0", "artist 0", "song 1", … */ + fun batchSongArgs(rowCount: Int): Array = + Array(rowCount * 2) { i -> if (i % 2 == 0) "song ${i / 2}" else "artist ${i / 2}" } + + suspend fun execute(context: Context, demo: SqlDemo, heavy: Boolean): String = + when (demo) { + SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) + SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) + SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) + SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) + SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) + SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) + } + + // --- 1. SentrySQLiteDriver, used directly ------------------------------------------------- + + private fun driverDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.driverDirectLock) { + val connection = SampleDatabases.driverConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + 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()) { + // Consumption: pull each column across the JNI boundary into the ART heap. + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "Driver (Direct): ${count(connection)} rows." + } + + private fun insert(connection: SQLiteConnection, title: String, artist: String) { + connection.prepare(INSERT_SONG).use { statement -> + statement.bindText(1, title) + statement.bindText(2, artist) + statement.step() + } + } + + private fun count(connection: SQLiteConnection): Long = + connection.prepare(COUNT_SONGS).use { statement -> + if (statement.step()) statement.getLong(0) else 0 + } + + // --- 2. SentrySQLiteDriver, used through Room 2.7+ ---------------------------------------- + + private suspend fun driverWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.driverRoom2Db(context).songDao(), "Driver (Room 2)", heavy) + + /** + * Shared Room 2 demo so the driver and open-helper paths run *identical* SQL. The only difference + * is how each integration instruments it: the driver spans every read, while the open helper's + * Room reads go via `moveToNext()` and emit no span, so only the INSERTs are spanned. + */ + private suspend fun roomDemo(dao: SongDao, label: String, heavy: Boolean): String { + dao.insert(SongEntity(title = "Spiders (Kidsmoke)", artist = "Wilco")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a per-row loop. + dao.insertAll(List(HEAVY_ROW_COUNT) { SongEntity(title = "song $it", artist = "artist $it") }) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "$label: ${dao.count()} rows." + } + + // --- 2b. SentrySQLiteDriver, used through Room 3.0+ (androidx.room3) ----------------------- + + private suspend fun driverWithRoom3(context: Context, heavy: Boolean): String { + val dao = SampleDatabases.driverRoom3Db(context).songDao() + dao.insert(SongEntity3(title = "What's Up", artist = "4 Non Blondes")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a naive per-row + // loop. + dao.insertAll( + List(HEAVY_ROW_COUNT) { SongEntity3(title = "song $it", artist = "artist $it") } + ) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "Driver (Room 3): ${dao.count()} rows." + } + + // --- 3. SentrySupportSQLiteOpenHelper, used directly -------------------------------------- + + private fun openHelperDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.openHelperDirectLock) { + // Runs the *same* SQL as driverDirect(), so the only difference you see in the Sentry UI is + // how each integration instruments identical statements. + val db = SampleDatabases.directHelper(context).writableDatabase + db.execSQL(INSERT_SONG, arrayOf("Mishima / Closing", "Philip Glass")) + db.execSQL(INSERT_SONG, arrayOf("School of Velocity, op 299 no 1, ", "Carl Czerny")) + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + db.execSQL(insertSongsBatch(HEAVY_ROW_COUNT), batchSongArgs(HEAVY_ROW_COUNT)) + db.query(SELECT_SONGS).use { cursor -> + while (cursor.moveToNext()) { + // Consumption: read each column out of the cursor window. + val row = "${cursor.getLong(0)}:${cursor.getString(1)}:${cursor.getString(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "OpenHelper (Direct): ${querySongCount(db)} rows." + } + + /** + * Runs the shared `SELECT count(*)` through the open helper and returns the value, read the + * normal way: moveToFirst() + getInt(). These are delegated straight to the underlying cursor + * (the open helper only instruments getCount()/onMove()/fillWindow()), so this read produces no + * `db.sql.query` span — the same as a real app reading a scalar count. + */ + private fun querySongCount(db: SupportSQLiteDatabase): Int = + db.query(COUNT_SONGS).use { cursor -> + cursor.moveToFirst() + cursor.getInt(0) + } + + // --- 4. SentrySupportSQLiteOpenHelper, used through Room ---------------------------------- + + // Runs the same [roomDemo] SQL as the driver path; only the instrumentation differs. + private suspend fun openHelperWithRoom(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.openHelperRoomDb(context).songDao(), "OpenHelper (Room)", heavy) + + // --- 5. SentrySupportSQLiteOpenHelper, used through SQLDelight ---------------------------- + + private fun openHelperWithSqlDelight(context: Context, heavy: Boolean): String { + val database = SampleSQLDelightDatabase(SampleDatabases.sqlDelightDriver(context)) + database.songQueries.insertSong("Nightcall", "Kavinsky") + if (heavy) { + // Wrap the batch in one transaction, vs. each insertSong() naively committing on its own. + database.transaction { + repeat(HEAVY_ROW_COUNT) { database.songQueries.insertSong("song $it", "artist $it") } + } + database.songQueries.selectAll().executeAsList().forEach { + appWork("${it.id}:${it.title}:${it.artist}") + } + } + // SQLDelight reads its cursor only via moveToNext(), which is delegated past the wrapper, so + // this count read produces no span. + val count = database.songQueries.countSongs().executeAsOne() + return "OpenHelper (SQLDelight): $count rows." + } + + /** + * Simulates per-row application-level work (e.g. decrypting a column) on consumed results. This + * is deliberately CPU-heavy and unrelated to the SQLite engine. + */ + private fun appWork(value: String) { + val digest = java.security.MessageDigest.getInstance("SHA-256") + var bytes = value.toByteArray() + repeat(500) { bytes = digest.digest(bytes) } + } +} 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 new file mode 100644 index 0000000000..036ba4332a --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt @@ -0,0 +1,64 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Activity that lets us simulate SDK auto-generation of a `ui.load` transaction + attach SQLite + * statement spans to it. + * + * Timing note: the work runs off the main thread, so it finishes after the screen is first drawn. + * Time-to-full-display tracing (enabled in the manifest) keeps the `ui.load` transaction open until + * [Sentry.reportFullyDisplayed], which we call once the work completes — otherwise the transaction + * would auto-finish at first display and the late db spans would have nowhere to attach. + */ +class UiLoadActivity : ComponentActivity() { + + private var status by mutableStateOf("Running under the screen's auto ui.load transaction…") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val id = SqlDemo.valueOf(intent.getStringExtra(EXTRA_DEMO_ID).orEmpty()) + val heavy = intent.getBooleanExtra(EXTRA_HEAVY, false) + + setContent { UiLoadScreen(status = status, onClose = ::finish) } + + // No Sentry.startTransaction(): the work runs under the auto ui.load:UiLoadActivity span. + lifecycleScope.launch { + status = + try { + val result = + withContext(Dispatchers.IO) { SqlStatements.execute(applicationContext, id, heavy) } + "$result\n\nRan under the auto ui.load transaction." + } catch (t: Throwable) { + "Load failed: ${t.message}" + } finally { + // Close the TTFD window so the ui.load transaction finishes with the db spans attached. + Sentry.reportFullyDisplayed() + } + } + } + + companion object { + private const val EXTRA_DEMO_ID = "demo_id" + private const val EXTRA_HEAVY = "heavy" + + /** Builds the intent that runs [id] (honoring the [heavy] toggle) on this UiLoadScreen. */ + fun intent(context: Context, id: SqlDemo, heavy: Boolean): Intent = + Intent(context, UiLoadActivity::class.java) + .putExtra(EXTRA_DEMO_ID, id.name) + .putExtra(EXTRA_HEAVY, heavy) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt new file mode 100644 index 0000000000..6495726448 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt @@ -0,0 +1,110 @@ +package io.sentry.samples.android.sqlite + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.sentry.samples.android.R + +private val ShimmerHighlight = Color(0xFFBDBDBD) + +@Composable +fun UiLoadScreen(status: String, onClose: () -> Unit) { + MaterialTheme { + Surface { + Box( + modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().padding(24.dp) + ) { + Column( + modifier = Modifier.align(Alignment.Center).fillMaxWidth().offset(y = (-48).dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShimmerSentryGlyph(modifier = Modifier.size(96.dp)) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = status, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } + + Button( + onClick = onClose, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + colors = + ButtonDefaults.buttonColors(containerColor = Color.Black, contentColor = Color.White), + ) { + Text("Close") + } + } + } + } +} + +@Composable +private fun ShimmerSentryGlyph(modifier: Modifier = Modifier) { + val progress = remember { Animatable(0f) } + LaunchedEffect(Unit) { + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 700, delayMillis = 250, easing = LinearEasing), + ) + } + + Image( + painter = painterResource(R.drawable.sentry_glyph), + contentDescription = "Sentry", + modifier = + modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val p = progress.value + val band = size.width * 0.5f + // Sweep the highlight band diagonally from off the bottom-left corner (p=0) to off the + // top-right corner (p=1): x travels left→right, y travels bottom→top. + val x = -band + (size.width + 2f * band) * p + val y = (size.height + band) - (size.height + 2f * band) * p + drawRect( + brush = + Brush.linearGradient( + colors = listOf(Color.Black, ShimmerHighlight, Color.Black), + start = Offset(x, y), + end = Offset(x + band, y - band), + ), + blendMode = BlendMode.SrcAtop, + ) + }, + ) +}