diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d5107ae2836..1b3c05168e4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -103,6 +103,7 @@ 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-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidxNavigation" }
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" }
diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts
index 31009f6dbb9..6cb2302d7c7 100644
--- a/sentry-samples/sentry-samples-android/build.gradle.kts
+++ b/sentry-samples/sentry-samples-android/build.gradle.kts
@@ -185,6 +185,7 @@ dependencies {
implementation(projects.sentryAndroid)
implementation(projects.sentryAndroidFragment)
+ implementation(projects.sentryAndroidNavigation)
implementation(projects.sentryAndroidSqlite)
implementation(projects.sentryAndroidTimber)
implementation(projects.sentryCompose)
@@ -209,6 +210,7 @@ dependencies {
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.navigation.fragment)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.browser)
implementation(libs.androidx.room3.runtime)
diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
index ac53c538de5..8e89f1e4380 100644
--- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
+++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
@@ -101,6 +101,17 @@
android:name=".compose.ComposeActivity"
android:exported="false" />
+
+
+
+
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt
index eb18b961534..1ae4bc1181a 100644
--- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt
@@ -893,6 +893,20 @@ fun IntegrationsScreen() {
}
}
}
+ item {
+ SentryTraced("open_nav2_activity") {
+ OutlinedButton(
+ onClick = {
+ activity.startActivity(
+ Intent(activity, io.sentry.samples.android.navigation.Nav2SetupActivity::class.java)
+ )
+ },
+ modifier = Modifier,
+ ) {
+ Text("Open Nav2Activity", maxLines = 2, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ }
item {
SentryTraced("open_sample_fragment") {
OutlinedButton(
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/SampleBeforeSendTransactionHook.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/SampleBeforeSendTransactionHook.kt
new file mode 100644
index 00000000000..6d79a2735c8
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/SampleBeforeSendTransactionHook.kt
@@ -0,0 +1,75 @@
+package io.sentry.samples.android
+
+import io.sentry.SentryOptions
+import io.sentry.protocol.SentryTransaction
+import java.util.concurrent.CopyOnWriteArrayList
+
+/**
+ * Owns the single global beforeSendTransaction hook used by sample screens that inspect finished
+ * transactions.
+ *
+ * Multiple sample activities can overlap briefly during configuration changes or relaunches. If
+ * each screen installs and restores its own callback, a later uninstall can resurrect a stale
+ * wrapper callback that still retains dead state and drops the original callback chain.
+ *
+ * Keep one stable callback installed and let sample screens register lightweight listeners against
+ * it instead.
+ */
+internal object SampleBeforeSendTransactionHook {
+
+ private val listeners = CopyOnWriteArrayList<(SentryTransaction, String?) -> Unit>()
+
+ @Volatile private var installedCallback: SentryOptions.BeforeSendTransactionCallback? = null
+ @Volatile private var previousCallback: SentryOptions.BeforeSendTransactionCallback? = null
+
+ fun installIfNeeded(options: SentryOptions) {
+ if (installedCallback != null) {
+ return
+ }
+
+ synchronized(this) {
+ if (installedCallback != null) {
+ return
+ }
+
+ previousCallback = options.beforeSendTransaction
+ val callback = SentryOptions.BeforeSendTransactionCallback { transaction, hint ->
+ val previous = previousCallback
+
+ val processedTransaction =
+ if (previous == null) {
+ transaction
+ } else {
+ previous.execute(transaction, hint)
+ }
+
+ processedTransaction?.let { processed ->
+ listeners.forEach { listener ->
+ try {
+ listener(processed, options.dsn)
+ } catch (e: RuntimeException) {
+ options.logger.log(
+ io.sentry.SentryLevel.ERROR,
+ "Sample transaction listener failed.",
+ e,
+ )
+ }
+ }
+ }
+
+ processedTransaction
+ }
+
+ installedCallback = callback
+ options.beforeSendTransaction = callback
+ }
+ }
+
+ fun addListener(listener: (SentryTransaction, String?) -> Unit) {
+ listeners.addIfAbsent(listener)
+ }
+
+ fun removeListener(listener: (SentryTransaction, String?) -> Unit) {
+ listeners.remove(listener)
+ }
+}
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2Activity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2Activity.kt
new file mode 100644
index 00000000000..2067e4e8d1d
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2Activity.kt
@@ -0,0 +1,639 @@
+package io.sentry.samples.android.navigation
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Intent
+import android.content.res.ColorStateList
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.RippleDrawable
+import android.net.Uri
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.setPadding
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import androidx.navigation.NavOptions
+import androidx.navigation.fragment.NavHostFragment
+import io.sentry.Sentry
+import io.sentry.SpanStatus
+import io.sentry.android.navigation.SentryNavigationListener
+import io.sentry.samples.android.GithubAPI
+import io.sentry.samples.android.R
+import io.sentry.samples.android.Repo
+import io.sentry.samples.android.navigation.Nav2Destination.Home
+import io.sentry.samples.android.navigation.Nav2Destination.Landing
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+/**
+ * Sample activity for testing Sentry's
+ * [NavController-based](https://developer.android.com/guide/navigation) integrations (aka, "Nav2").
+ *
+ * Exercises [SentryNavigationListener] both directly via fragment navigation ("Fragments" and "Deep
+ * Link (Fragments)" tabs) and through our composable [withSentryObservableEffect] wrapper
+ * ("Compose" tab).
+ *
+ * Developers can also stress test our Nav2 integration via the "Performance" tab.
+ */
+class Nav2Activity : AppCompatActivity() {
+
+ private lateinit var navController: NavController
+
+ /**
+ * Sample-only mirror of [NavController] state used to display the current stack, drive deep-link
+ * helpers, and keep shared destinations attributed to the active scenario.
+ */
+ private val backStack = mutableListOf(Home)
+
+ private lateinit var sentryNavigationListener: SentryNavigationListener
+ private val enableNavigationBreadcrumbs = mutableStateOf(true)
+ private val enableNavigationTransactions = mutableStateOf(true)
+
+ private lateinit var previousConfig: Nav2SampleConfigSnapshot
+
+ // Top bar config
+ private val routeWorkOptions =
+ mutableStateOf(setOf(RouteWorkOption.HTTP_REQUEST, RouteWorkOption.MANUAL_CHILD_SPAN))
+ private lateinit var topBar: Nav2TopBar
+ private var activeScenario = Nav2Scenario.COMPOSE
+
+ // Main content
+ private lateinit var contentHosts: Nav2ContentHosts
+ private val performanceState = NavigationPerformanceState()
+
+ // Transaction history bottom sheet
+ private var isTransactionHistoryActive = false
+ private val transactionHistory = Nav2TransactionHistory(isActive = { isTransactionHistoryActive })
+ private val showTransactionHistorySheet = mutableStateOf(false)
+ private var showActivityUiLoadTransactionDelayMessage = false
+ private lateinit var transactionHistoryOverlay: ComposeView
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ previousConfig = intent.previousNav2SampleConfigSnapshot(currentNav2SampleConfigSnapshot())
+
+ val configuration = intent.nav2SampleConfig()
+ configuration.applyToCurrentOptions()
+ enableNavigationBreadcrumbs.value = configuration.enableNavigationBreadcrumbs
+ enableNavigationTransactions.value = configuration.enableNavigationTransactions
+ showActivityUiLoadTransactionDelayMessage = configuration.hasOnlyActivityUiLoadTransactions
+
+ transactionHistory.install()
+
+ activeScenario =
+ if (configuration.enableActivityUiLoadTransaction) {
+ Nav2Scenario.COMPOSE
+ } else {
+ Nav2Scenario.LANDING
+ }
+
+ sentryNavigationListener = createSentryNavigationListener()
+
+ val navHostId = View.generateViewId()
+ setContentView(createContentView(navHostId))
+
+ val navHostFragment = NavHostFragment.create(R.navigation.nav2_sample)
+ supportFragmentManager
+ .beginTransaction()
+ .replace(navHostId, navHostFragment)
+ .setPrimaryNavigationFragment(navHostFragment)
+ .commitNow()
+
+ navController = navHostFragment.navController
+
+ if (!configuration.enableActivityUiLoadTransaction) {
+ val graph = navController.navInflater.inflate(R.navigation.nav2_sample)
+ graph.setStartDestination(R.id.nav2_landing)
+ navController.graph = graph
+ backStack.resetTo(Landing)
+ }
+
+ navController.addOnDestinationChangedListener(sentryNavigationListener)
+ navController.addOnDestinationChangedListener { _, destination, arguments ->
+ updateNavigationUi(destination, arguments)
+ }
+
+ openScenario(activeScenario)
+ }
+
+ override fun onStart() {
+ super.onStart()
+ isTransactionHistoryActive = true
+ }
+
+ override fun onStop() {
+ isTransactionHistoryActive = false
+ performanceState.stopAutomaticWork()
+ super.onStop()
+ }
+
+ override fun onDestroy() {
+ if (isFinishing) {
+ previousConfig.applyToCurrentOptions()
+ }
+ mainHandler.removeCallbacksAndMessages(null)
+ transactionHistory.uninstall()
+ super.onDestroy()
+ }
+
+ internal fun navigateTo(destination: Nav2Destination) {
+ backStack.add(destination)
+ try {
+ navController.navigate(destination.id, destination.arguments)
+ } catch (e: IllegalArgumentException) {
+ backStack.removeAt(backStack.lastIndex)
+ throw e
+ } catch (e: IllegalStateException) {
+ backStack.removeAt(backStack.lastIndex)
+ throw e
+ }
+ }
+
+ internal fun navigateBack() {
+ backStack.popTrackedBackStack { navController.popBackStack() }
+ }
+
+ internal fun resetToHome() {
+ backStack.resetTo(Home)
+ if (!navController.popBackStack(R.id.nav2_home, false)) {
+ navController.setGraph(R.navigation.nav2_sample)
+ }
+ }
+
+ internal fun openSyntheticProductDeepLink() {
+ resetToHome()
+ navigateTo(Nav2Destination.ProductList)
+ navigateTo(Nav2Destination.ProductDetail("42", "deep-link", "email"))
+ }
+
+ private fun createSentryNavigationListener(): SentryNavigationListener {
+ return SentryNavigationListener(
+ enableNavigationBreadcrumbs = enableNavigationBreadcrumbs.value,
+ enableNavigationTracing = enableNavigationTransactions.value,
+ )
+ }
+
+ private fun createComposeNavHostView(): ComposeView =
+ ComposeView(this).apply {
+ setBackgroundColor(color(android.R.color.white))
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+
+ setContent {
+ MaterialTheme {
+ Nav2ComposeApp(
+ navListener = sentryNavigationListener,
+ routeWorkOptions = routeWorkOptions.value,
+ onCaptureException = { captureSampleException("Nav2") },
+ onCrashApp = { showCrashConfirmation("Nav2") },
+ onRouteChanged = { _, currentRoute, backStack ->
+ updateComposeNavigationUi(currentRoute, backStack)
+ },
+ )
+ }
+ }
+ }
+
+ private fun createContentView(navHostId: Int): View {
+ val root = FrameLayout(this).apply { layoutParams = matchParentParams() }
+ val mainContent =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ layoutParams = matchParentParams()
+ ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ view.setPadding(0, systemBars.top, 0, systemBars.bottom)
+ insets
+ }
+ }
+
+ topBar =
+ Nav2TopBar(
+ context = this,
+ onTransactionHistoryClick = { showTransactionHistorySheet() },
+ onRouteWorkSettingsClick = { showRouteWorkDialog() },
+ onScenarioClick = { scenario -> openScenario(scenario) },
+ )
+ contentHosts =
+ Nav2ContentHosts(
+ context = this,
+ navHostId = navHostId,
+ createComposeContent = { createComposeNavHostView() },
+ createPerformanceContent = { createPerformanceView() },
+ )
+
+ mainContent.addView(topBar.view)
+ mainContent.addView(contentHosts.view)
+ mainContent.addView(createBottomBar())
+
+ root.addView(mainContent, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
+ root.addView(
+ createTransactionHistoryOverlay(),
+ FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT),
+ )
+
+ return root
+ }
+
+ private fun createPerformanceView(): ComposeView =
+ ComposeView(this).apply {
+ setBackgroundColor(color(android.R.color.white))
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+
+ setContent {
+ MaterialTheme {
+ NavigationPerformancePanel(
+ title = "Performance",
+ description =
+ "Stress the Nav2 listener path with deep fragment back stacks, destination changes, " +
+ "and unrelated Compose recompositions. Use Perfetto sections prefixed with " +
+ "Nav2Stress to inspect hot paths.",
+ currentRoute = "/${backStack.lastOrNull()?.routeName ?: Nav2RouteNames.HOME}",
+ backStack =
+ navigationPerformanceBackStackPreview(
+ backStack.map { destination -> "/${destination.routeName}" }
+ ),
+ state = performanceState,
+ onBuildStack = { buildNav2PerformanceStack() },
+ onReplaceTop = { replaceNav2PerformanceTop() },
+ )
+ }
+ }
+ }
+
+ private fun createTransactionHistoryOverlay(): ComposeView =
+ ComposeView(this).apply {
+ transactionHistoryOverlay = this
+ visibility = View.GONE
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+
+ setContent {
+ MaterialTheme {
+ if (showTransactionHistorySheet.value) {
+ Nav2TransactionHistorySheet(
+ transactions = transactionHistory.transactions,
+ showActivityUiLoadTransactionDelayMessage = showActivityUiLoadTransactionDelayMessage,
+ onDismissRequest = { hideTransactionHistorySheet() },
+ onOpenTransaction = { url -> openTransactionInSentry(url) },
+ onDumpTransactionUrl = { url -> dumpTransactionUrl(url) },
+ onCopyTransactionUrl = { url -> copyTransactionUrl(url) },
+ )
+ }
+ }
+ }
+ }
+
+ private fun createBottomBar(): View {
+ return LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER
+ setPadding(12.dp)
+ addView(
+ LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER
+ layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ addView(
+ sentryEventButton("Capture Exception", R.id.nav2_capture_exception) {
+ captureSampleException("Nav2")
+ }
+ )
+ addView(
+ sentryEventButton("Crash App", R.id.nav2_crash_app) { showCrashConfirmation("Nav2") }
+ )
+ }
+ )
+ }
+ }
+
+ private fun openScenario(scenario: Nav2Scenario) {
+ activeScenario = scenario
+ topBar.select(activeScenario)
+ performanceState.stopAutomaticWork()
+ when (scenario) {
+ Nav2Scenario.LANDING -> {
+ contentHosts.showFragments()
+ updateNavigationUi(
+ navController.currentDestination,
+ navController.currentBackStackEntry?.arguments,
+ )
+ }
+ Nav2Scenario.COMPOSE -> contentHosts.showCompose()
+ Nav2Scenario.FRAGMENTS -> {
+ contentHosts.showFragments()
+ resetToHome()
+ updateNavigationUi(
+ navController.currentDestination,
+ navController.currentBackStackEntry?.arguments,
+ )
+ }
+ Nav2Scenario.DEEP_LINK -> {
+ contentHosts.showFragments()
+ resetToDestination(Nav2Destination.DeepLink)
+ updateNavigationUi(
+ navController.currentDestination,
+ navController.currentBackStackEntry?.arguments,
+ )
+ }
+ Nav2Scenario.PERFORMANCE -> {
+ performanceState.resetCounters()
+ contentHosts.showPerformance()
+ updatePerformanceTopBar()
+ }
+ }
+ }
+
+ internal fun runRouteWorkAction(routeName: String) {
+ RouteWorkOption.entries.forEach { option ->
+ if (option !in routeWorkOptions.value) {
+ return@forEach
+ }
+
+ tagNav2SampleAction(option.tagName, routeName)
+
+ when (option) {
+ RouteWorkOption.HTTP_REQUEST -> {
+ GithubAPI.service
+ .listRepos("getsentry")
+ .enqueue(
+ object : Callback> {
+ override fun onResponse(call: Call>, response: Response>) {
+ Thread { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }.start()
+ }
+
+ override fun onFailure(call: Call>, t: Throwable) {
+ Sentry.captureException(t)
+ Thread { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }.start()
+ }
+ }
+ )
+ }
+
+ RouteWorkOption.MANUAL_CHILD_SPAN -> recordManualChildSpan(routeName)
+ }
+ }
+ }
+
+ private fun updatePerformanceTopBar() {
+ topBar.update(
+ scenario = Nav2Scenario.PERFORMANCE,
+ currentRoute = "/${backStack.lastOrNull()?.routeName ?: Nav2RouteNames.HOME}",
+ backStack =
+ navigationPerformanceBackStackPreview(
+ backStack.map { destination -> "/${destination.routeName}" }
+ ),
+ )
+ }
+
+ private fun buildNav2PerformanceStack() {
+ traceNavigationPerformanceSection("Nav2Stress.buildStack") {
+ val generation = performanceState.nextGeneration()
+ resetToHome()
+ for (index in 1 until performanceState.stackDepth.coerceAtLeast(1)) {
+ navigateTo(nav2PerformanceDestination(index, generation))
+ }
+ performanceState.markNavigationMutation()
+ }
+ }
+
+ private fun replaceNav2PerformanceTop() {
+ traceNavigationPerformanceSection("Nav2Stress.replaceTop") {
+ val generation = performanceState.nextGeneration()
+ if (backStack.size > 1) {
+ navigateBack()
+ }
+ navigateTo(nav2PerformanceDestination(backStack.size, generation))
+ performanceState.markNavigationMutation()
+ }
+ }
+
+ private fun nav2PerformanceDestination(index: Int, generation: Int): Nav2Destination =
+ when (index % 4) {
+ 0 -> Nav2Destination.ProductList
+ 1 ->
+ Nav2Destination.ProductDetail(
+ productId = "perf-$index",
+ source = "performance",
+ campaign = "generation-$generation",
+ )
+ 2 -> Nav2Destination.Checkout(productId = "perf-$index")
+ else -> Nav2Destination.Confirmation(orderId = "perf-$generation-$index")
+ }
+
+ private fun resetToDestination(destination: Nav2Destination) {
+ backStack.resetTo(destination)
+
+ val isStartingFromLanding = navController.currentDestination?.id == R.id.nav2_landing
+ val popUpToDestination = if (isStartingFromLanding) R.id.nav2_landing else R.id.nav2_home
+
+ navController.navigate(
+ destination.id,
+ destination.arguments,
+ NavOptions.Builder()
+ .setPopUpTo(popUpToDestination, isStartingFromLanding)
+ .setLaunchSingleTop(true)
+ .build(),
+ )
+ }
+
+ private fun updateNavigationUi(destination: NavDestination?, arguments: Bundle?) {
+ syncTrackedBackStack(destination, arguments)
+
+ val trackedDestination = backStack.lastOrNull()
+ val routeName = trackedDestination?.routeName ?: destination?.routeName() ?: Nav2RouteNames.HOME
+ val currentRoute =
+ trackedDestination?.displayRoute() ?: Nav2RouteSpecs.get(routeName).displayRoute(arguments)
+
+ val scenario = fragmentTopBarScenario(trackedDestination)
+ topBar.update(
+ scenario = scenario,
+ currentRoute = currentRoute,
+ backStack = navControllerBackStack(),
+ )
+
+ if (activeScenario == Nav2Scenario.PERFORMANCE) {
+ performanceState.markDestinationChange()
+ }
+ }
+
+ private fun updateComposeNavigationUi(currentRoute: String, backStack: String) {
+ topBar.update(
+ scenario = Nav2Scenario.COMPOSE,
+ currentRoute = currentRoute,
+ backStack = backStack,
+ )
+ }
+
+ private fun navControllerBackStack(): String {
+ return backStack.joinToString(" -> ") { destination -> "/${destination.routeName}" }
+ }
+
+ private fun fragmentTopBarScenario(destination: Nav2Destination?): Nav2Scenario {
+ if (activeScenario == Nav2Scenario.PERFORMANCE) {
+ return Nav2Scenario.PERFORMANCE
+ }
+
+ return when (destination) {
+ Nav2Destination.Landing -> Nav2Scenario.LANDING
+ Nav2Destination.DeepLink -> Nav2Scenario.DEEP_LINK
+ is Nav2Destination.ProductDetail -> {
+ if (destination.source == "deep-link") {
+ Nav2Scenario.DEEP_LINK
+ } else {
+ Nav2Scenario.FRAGMENTS
+ }
+ }
+ is Nav2Destination.PromoDialog -> destination.scenario
+ is Nav2Destination.ShareSheet -> destination.scenario
+ else -> Nav2Scenario.FRAGMENTS
+ }
+ }
+
+ private fun syncTrackedBackStack(destination: NavDestination?, arguments: Bundle?) {
+ if (destination == null || backStack.lastOrNull()?.matches(destination, arguments) == true) {
+ return
+ }
+
+ val destinationIndex = backStack.indexOfLast { trackedDestination ->
+ trackedDestination.matches(destination, arguments)
+ }
+ if (destinationIndex >= 0) {
+ backStack.subList(destinationIndex + 1, backStack.size).clear()
+ return
+ }
+
+ destination.toNav2Destination(arguments)?.let { backStack.resetTo(it) }
+ }
+
+ internal fun cancelCurrentUiLoadTransaction() {
+ Sentry.configureScope { scope ->
+ scope.withTransaction { transaction ->
+ if (transaction?.operation == ACTIVITY_UI_LOAD_OP) {
+ transaction.forceFinish(SpanStatus.CANCELLED, false, null)
+ scope.clearTransaction()
+ }
+ }
+ }
+ }
+
+ internal fun tagCurrentScenarioOnTransaction() {
+ tagCurrentNav2Scenario(activeScenario)
+ }
+
+ private fun showTransactionHistorySheet() {
+ transactionHistoryOverlay.visibility = View.VISIBLE
+ showTransactionHistorySheet.value = true
+ }
+
+ private fun hideTransactionHistorySheet() {
+ showTransactionHistorySheet.value = false
+ mainHandler.postDelayed(
+ {
+ if (!showTransactionHistorySheet.value) {
+ transactionHistoryOverlay.visibility = View.GONE
+ }
+ },
+ BOTTOM_SHEET_HIDE_DELAY_MILLIS,
+ )
+ }
+
+ private fun openTransactionInSentry(url: String) {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ }
+
+ private fun dumpTransactionUrl(url: String) {
+ Log.i(TAG, "Sentry transaction URL: $url")
+ Toast.makeText(this, "Dumped transaction URL to logcat.", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun copyTransactionUrl(url: String) {
+ val clipboard = getSystemService(ClipboardManager::class.java)
+ clipboard.setPrimaryClip(ClipData.newPlainText("Sentry transaction URL", url))
+ Toast.makeText(this, "Copied transaction URL to clipboard.", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun sentryEventButton(label: String, id: Int, onClick: () -> Unit): Button =
+ Button(this).apply {
+ this.id = id
+ text = label
+ isAllCaps = false
+ setTextColor(color(android.R.color.white))
+ background =
+ RippleDrawable(
+ ColorStateList.valueOf(0x33FFFFFF),
+ GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ cornerRadius = 20.dp.toFloat()
+ setColor(color(R.color.colorAccentSoft))
+ },
+ GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ cornerRadius = 20.dp.toFloat()
+ setColor(color(android.R.color.white))
+ },
+ )
+ stateListAnimator = null
+ elevation = 0f
+ translationZ = 0f
+ setOnClickListener { onClick() }
+ layoutParams =
+ LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f).apply {
+ setMargins(6.dp, 0, 6.dp, 0)
+ }
+ }
+
+ private fun showRouteWorkDialog() {
+ showRouteWorkDialog(this, routeWorkOptions.value) { selectedOptions ->
+ routeWorkOptions.value = selectedOptions
+ }
+ }
+
+ internal fun captureSampleException(navName: String) {
+ Sentry.captureException(RuntimeException("$navName sample exception button"))
+ Thread { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }.start()
+ }
+
+ internal fun showCrashConfirmation(navName: String) {
+ AlertDialog.Builder(this)
+ .setTitle("Crash app?")
+ .setMessage("This will throw an uncaught exception and close the sample app.")
+ .setNegativeButton("Cancel", null)
+ .setPositiveButton("Crash") { _, _ -> crashSampleApp(navName) }
+ .show()
+ }
+
+ private fun crashSampleApp(navName: String): Nothing {
+ throw RuntimeException("Fatal $navName sample crash button")
+ }
+
+ private val Int.dp: Int
+ get() = (this * resources.displayMetrics.density).toInt()
+
+ private fun color(id: Int): Int = getColor(id)
+
+ private fun matchParentParams(): ViewGroup.LayoutParams =
+ ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+}
+
+private const val BOTTOM_SHEET_HIDE_DELAY_MILLIS = 350L
+private const val ACTIVITY_UI_LOAD_OP = "ui.load"
+private const val TAG = "Nav2Activity"
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt
new file mode 100644
index 00000000000..2bb8c359f11
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt
@@ -0,0 +1,698 @@
+package io.sentry.samples.android.navigation
+
+import android.os.Bundle
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog as ComposeAlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.dialog
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import io.sentry.Sentry
+import io.sentry.android.navigation.SentryNavigationListener
+import io.sentry.compose.SentryModifier.sentryTag
+import io.sentry.compose.SentryTraced
+import io.sentry.compose.withSentryObservableEffect
+import io.sentry.samples.android.GithubAPI
+import io.sentry.samples.android.navigation.Nav2ComposeDestination.Checkout
+import io.sentry.samples.android.navigation.Nav2ComposeDestination.Confirmation
+import io.sentry.samples.android.navigation.Nav2ComposeDestination.Home
+import io.sentry.samples.android.navigation.Nav2ComposeDestination.ProductDetail
+import io.sentry.samples.android.navigation.Nav2ComposeDestination.ProductList
+import io.sentry.samples.android.navigation.Nav2ComposeDestination.PromoDialog
+import java.io.IOException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import retrofit2.HttpException
+
+@Composable
+internal fun Nav2ComposeApp(
+ navListener: SentryNavigationListener,
+ routeWorkOptions: Set,
+ onCaptureException: () -> Unit,
+ onCrashApp: () -> Unit,
+ onRouteChanged: (routeName: String, currentRoute: String, backStack: String) -> Unit,
+) {
+
+ val navController = rememberNavController().withSentryObservableEffect(navListener = navListener)
+ val backStack = rememberSaveableNav2ComposeBackStack()
+ val shareSheetProductId = rememberSaveable { mutableStateOf(null) }
+ val currentDestination = backStack.lastOrNull() ?: Home
+
+ fun navigateTo(destination: Nav2ComposeDestination) {
+ backStack.add(destination)
+ navController.navigate(destination.route)
+ }
+
+ fun navigateBack() {
+ backStack.popTrackedBackStack { navController.popBackStack() }
+ }
+
+ fun openShareSheet(productId: String) {
+ shareSheetProductId.value = productId
+ }
+
+ fun dismissShareSheet() {
+ if (shareSheetProductId.value == null) {
+ return
+ }
+ shareSheetProductId.value = null
+ }
+
+ fun resetToHome() {
+ backStack.resetTo(Home)
+ shareSheetProductId.value = null
+ navController.navigate(Home.route) {
+ popUpTo(Home.route) { inclusive = false }
+ launchSingleTop = true
+ }
+ }
+
+ BackHandler(enabled = shareSheetProductId.value != null) { dismissShareSheet() }
+ BackHandler(enabled = shareSheetProductId.value == null && backStack.size > 1) { navigateBack() }
+
+ LaunchedEffect(currentDestination, backStack.size) {
+ onRouteChanged(
+ currentDestination.routeName,
+ currentDestination.displayRoute(),
+ backStack.toComposeBackStackText(),
+ )
+ }
+
+ RouteWorkEffect(
+ destination = currentDestination,
+ routeWorkOptions = routeWorkOptions,
+ )
+
+ Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
+ NavHost(
+ navController = navController,
+ startDestination = Home.route,
+ modifier = Modifier.weight(1f),
+ ) {
+ composable(Home.route) {
+ TracedNav2ComposeRoute(Home.routeName) {
+ Nav2ComposeHomeRoute(routeSpec = Nav2RouteSpecs.home) { navigateTo(ProductList) }
+ }
+ }
+
+ composable(ProductList.route) {
+ TracedNav2ComposeRoute(ProductList.routeName) {
+ Nav2ComposeProductListRoute(
+ routeSpec = Nav2RouteSpecs.productList,
+ onOpenProduct42 = {
+ navigateTo(
+ ProductDetail(
+ productId = "42",
+ source = "product-list",
+ campaign = "summer-sale",
+ )
+ )
+ },
+ onOpenProduct7 = {
+ navigateTo(ProductDetail(productId = "7", source = "product-list"))
+ },
+ )
+ }
+ }
+
+ composable(
+ route = Nav2ComposeDestination.PRODUCT_DETAIL_ROUTE,
+ arguments =
+ listOf(
+ navArgument(Nav2Args.PRODUCT_ID) { type = NavType.StringType },
+ navArgument(Nav2Args.SOURCE) { type = NavType.StringType },
+ navArgument(Nav2Args.CAMPAIGN) {
+ type = NavType.StringType
+ defaultValue = ""
+ },
+ ),
+ ) { entry ->
+ val productId = entry.arguments?.getString(Nav2Args.PRODUCT_ID).orEmpty()
+ val source = entry.arguments?.getString(Nav2Args.SOURCE).orEmpty()
+ val campaign = entry.arguments?.getString(Nav2Args.CAMPAIGN).orEmpty()
+ TracedNav2ComposeRoute(Nav2RouteNames.PRODUCT_DETAIL) {
+ Nav2ComposeProductDetailRoute(
+ routeSpec = Nav2RouteSpecs.productDetail,
+ productId = productId,
+ source = source,
+ campaign = campaign,
+ onShowPromoDialog = {
+ navigateTo(PromoDialog("detail-$productId"))
+ },
+ onOpenShareSheet = { openShareSheet(productId) },
+ onCheckout = { navigateTo(Checkout(productId)) },
+ )
+ }
+ }
+
+ composable(
+ route = Nav2ComposeDestination.CHECKOUT_ROUTE,
+ arguments = listOf(navArgument(Nav2Args.PRODUCT_ID) { type = NavType.StringType }),
+ ) { entry ->
+ val productId = entry.arguments?.getString(Nav2Args.PRODUCT_ID).orEmpty()
+ TracedNav2ComposeRoute(Nav2RouteNames.CHECKOUT) {
+ Nav2ComposeCheckoutRoute(
+ routeSpec = Nav2RouteSpecs.checkout,
+ productId = productId,
+ onCompleteOrder = {
+ navigateTo(Confirmation(orderId = "order-$productId"))
+ },
+ )
+ }
+ }
+
+ composable(
+ route = Nav2ComposeDestination.CONFIRMATION_ROUTE,
+ arguments = listOf(navArgument(Nav2Args.ORDER_ID) { type = NavType.StringType }),
+ ) { entry ->
+ TracedNav2ComposeRoute(Nav2RouteNames.CONFIRMATION) {
+ Nav2ComposeConfirmationRoute(
+ routeSpec = Nav2RouteSpecs.confirmation,
+ orderId = entry.arguments?.getString(Nav2Args.ORDER_ID).orEmpty(),
+ onResetBackStack = { resetToHome() },
+ )
+ }
+ }
+
+ dialog(
+ route = Nav2ComposeDestination.PROMO_DIALOG_ROUTE,
+ arguments = listOf(navArgument(Nav2Args.PROMO_ID) { type = NavType.StringType }),
+ ) { entry ->
+ // This dialog is a real Nav destination, so it participates in Nav2 the same way as the
+ // rest of the route graph. Compare it with the share sheet overlay below when inspecting
+ // Sentry's Nav2 breadcrumbs, destination arguments, and route transactions.
+ TracedNav2ComposeRoute(Nav2RouteNames.PROMO_DIALOG) {
+ Nav2ComposePromoDialogRoute(
+ routeSpec = Nav2RouteSpecs.promoDialog,
+ promoId = entry.arguments?.getString(Nav2Args.PROMO_ID).orEmpty(),
+ onCaptureException = onCaptureException,
+ onCrashApp = onCrashApp,
+ onDismiss = { navigateBack() },
+ )
+ }
+ }
+ }
+
+ shareSheetProductId.value?.let { productId ->
+ // This share sheet is intentionally just a screen overlay, not a Nav destination. It lets
+ // the sample compare how Sentry's Nav2 integration behaves for proper Nav destinations vs.
+ // UI layered on top of the current route.
+ Nav2ComposeShareSheetRoute(
+ routeSpec = Nav2RouteSpecs.shareSheet,
+ productId = productId,
+ onCaptureException = onCaptureException,
+ onCrashApp = onCrashApp,
+ onDone = ::dismissShareSheet,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+private fun TracedNav2ComposeRoute(routeName: String, content: @Composable BoxScope.() -> Unit) {
+ tagCurrentNav2Scenario(Nav2Scenario.COMPOSE)
+ SentryTraced(
+ tag = "Nav2 /$routeName",
+ // Keep interaction tagging off here so route wrappers do not turn every Compose click into a
+ // generic route-level interaction transaction.
+ enableUserInteractionTracing = false,
+ content = content,
+ )
+}
+
+@Composable
+private fun RouteWorkEffect(
+ destination: Nav2ComposeDestination,
+ routeWorkOptions: Set,
+) {
+ val currentOptions = rememberUpdatedState(routeWorkOptions)
+
+ if (RouteWorkOption.MANUAL_CHILD_SPAN in currentOptions.value) {
+ // Keep this synchronous to verify that Nav2 route transactions are bound before destination
+ // composition runs, not merely before destination effects are launched.
+ recordManualChildSpan(destination.routeName)
+ }
+
+ LaunchedEffect(destination) {
+ runRouteWork(
+ routeName = destination.routeName,
+ options = currentOptions.value,
+ )
+ }
+}
+
+private suspend fun runRouteWork(
+ routeName: String,
+ options: Set,
+) {
+ RouteWorkOption.entries.forEach { option ->
+ if (option !in options || option == RouteWorkOption.MANUAL_CHILD_SPAN) {
+ return@forEach
+ }
+
+ tagNav2SampleAction(option.tagName, routeName)
+
+ when (option) {
+ RouteWorkOption.HTTP_REQUEST -> {
+ try {
+ GithubAPI.service.listReposAsync("getsentry", 5)
+ } catch (e: IOException) {
+ Sentry.captureException(e)
+ } catch (e: HttpException) {
+ Sentry.captureException(e)
+ } finally {
+ withContext(Dispatchers.IO) { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }
+ }
+ }
+ RouteWorkOption.MANUAL_CHILD_SPAN -> Unit
+ }
+ }
+}
+
+@Composable
+private fun Nav2ComposeHomeRoute(routeSpec: Nav2RouteSpec, onBrowseProducts: () -> Unit) {
+ Nav2ComposeActionRoute(routeSpec, buttons = listOf("Browse Products" to onBrowseProducts))
+}
+
+@Composable
+private fun Nav2ComposeProductListRoute(
+ routeSpec: Nav2RouteSpec,
+ onOpenProduct42: () -> Unit,
+ onOpenProduct7: () -> Unit,
+) {
+ Nav2ComposeActionRoute(
+ routeSpec,
+ buttons = listOf("Open Product 42" to onOpenProduct42, "Open Product 7" to onOpenProduct7),
+ )
+}
+
+@Composable
+private fun Nav2ComposeProductDetailRoute(
+ routeSpec: Nav2RouteSpec,
+ productId: String,
+ source: String,
+ campaign: String,
+ onShowPromoDialog: () -> Unit,
+ onOpenShareSheet: () -> Unit,
+ onCheckout: () -> Unit,
+) {
+ LaunchedEffect(productId, source, campaign) {
+ recordSimulatedBackgroundSpan(Nav2RouteNames.PRODUCT_DETAIL)
+ }
+
+ Nav2ComposeActionRoute(
+ routeSpec,
+ arguments =
+ mapOf(
+ Nav2Args.PRODUCT_ID to productId,
+ Nav2Args.SOURCE to source,
+ Nav2Args.CAMPAIGN to campaign,
+ ),
+ buttons =
+ listOf(
+ "Show Promo Dialog" to onShowPromoDialog,
+ "Open Share Sheet" to onOpenShareSheet,
+ "Go to Checkout" to onCheckout,
+ ),
+ )
+}
+
+@Composable
+private fun Nav2ComposeCheckoutRoute(
+ routeSpec: Nav2RouteSpec,
+ productId: String,
+ onCompleteOrder: () -> Unit,
+) {
+ Nav2ComposeActionRoute(
+ routeSpec,
+ arguments = mapOf(Nav2Args.PRODUCT_ID to productId),
+ buttons = listOf("Complete Order" to onCompleteOrder),
+ )
+}
+
+@Composable
+private fun Nav2ComposeConfirmationRoute(
+ routeSpec: Nav2RouteSpec,
+ orderId: String,
+ onResetBackStack: () -> Unit,
+) {
+ Nav2ComposeActionRoute(
+ routeSpec,
+ arguments = mapOf(Nav2Args.ORDER_ID to orderId),
+ buttons = listOf("Reset Backstack" to onResetBackStack),
+ )
+}
+
+@Composable
+private fun Nav2ComposeActionRoute(
+ routeSpec: Nav2RouteSpec,
+ arguments: Map = emptyMap(),
+ buttons: List Unit>>,
+) {
+ Nav2ComposeRouteScaffold(routeSpec) {
+ routeSpec.displayArguments(arguments).forEach { (label, value) ->
+ Nav2ComposeRouteInfo(label, value)
+ }
+ buttons.forEach { (label, onClick) -> Nav2ComposeRouteButton(label, onClick) }
+ }
+}
+
+@Composable
+private fun Nav2ComposePromoDialogRoute(
+ routeSpec: Nav2RouteSpec,
+ promoId: String,
+ onCaptureException: () -> Unit,
+ onCrashApp: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ ComposeAlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(routeSpec.title) },
+ text = {
+ val argumentText =
+ routeSpec.displayArguments(mapOf(Nav2Args.PROMO_ID to promoId)).toDisplayString()
+ Text(
+ listOfNotNull(routeSpec.description, argumentText.takeIf { it.isNotEmpty() })
+ .joinToString("\n\n")
+ )
+ },
+ confirmButton = {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(
+ onClick = onCaptureException,
+ modifier = Modifier.sentryTag(nav2ComposeInteractionTag("Promo Dialog Exception")),
+ ) {
+ Text("Exception")
+ }
+ TextButton(
+ onClick = onCrashApp,
+ modifier = Modifier.sentryTag(nav2ComposeInteractionTag("Promo Dialog Crash App")),
+ ) {
+ Text("Crash App")
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ TextButton(
+ onClick = onDismiss,
+ modifier = Modifier.sentryTag(nav2ComposeInteractionTag("Promo Dialog Dismiss")),
+ ) {
+ Text("Dismiss", color = Color.Gray)
+ }
+ }
+ },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Nav2ComposeShareSheetRoute(
+ routeSpec: Nav2RouteSpec,
+ productId: String,
+ onCaptureException: () -> Unit,
+ onCrashApp: () -> Unit,
+ onDone: () -> Unit,
+) {
+ ModalBottomSheet(onDismissRequest = onDone) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(routeSpec.title, style = MaterialTheme.typography.headlineSmall)
+ routeSpec.description?.let { Text(it) }
+ routeSpec.displayArguments(mapOf(Nav2Args.PRODUCT_ID to productId)).forEach { (label, value)
+ ->
+ Text("$label=$value")
+ }
+ Button(
+ onClick = onCaptureException,
+ modifier =
+ Modifier.fillMaxWidth().sentryTag(nav2ComposeInteractionTag("Share Sheet Exception")),
+ ) {
+ Text("Capture Exception")
+ }
+ Button(
+ onClick = onCrashApp,
+ modifier =
+ Modifier.fillMaxWidth().sentryTag(nav2ComposeInteractionTag("Share Sheet Crash App")),
+ ) {
+ Text("Crash App")
+ }
+ Button(
+ onClick = onDone,
+ modifier = Modifier.fillMaxWidth().sentryTag(nav2ComposeInteractionTag("Share Sheet Done")),
+ ) {
+ Text("Done")
+ }
+ Spacer(Modifier.size(12.dp))
+ }
+ }
+}
+
+@Composable
+private fun Nav2ComposeRouteScaffold(
+ routeSpec: Nav2RouteSpec,
+ content: (@Composable ColumnScope.() -> Unit)? = null,
+) {
+ Column(
+ modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ routeSpec.title,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ routeSpec.description?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
+ if (content != null) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ content()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Nav2ComposeRouteButton(
+ label: String,
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = Modifier.fillMaxWidth().sentryTag(nav2ComposeInteractionTag(label)),
+ ) {
+ Text(label)
+ }
+}
+
+private fun nav2ComposeInteractionTag(label: String): String = "Nav2 Compose $label"
+
+@Composable
+private fun Nav2ComposeRouteInfo(label: String, value: String) {
+ Row(
+ modifier =
+ Modifier.fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(8.dp))
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(label, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.size(12.dp))
+ Text(value, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+}
+
+private fun SnapshotStateList.resetTo(destination: Nav2ComposeDestination) {
+ clear()
+ add(destination)
+}
+
+@Composable
+private fun rememberSaveableNav2ComposeBackStack(): SnapshotStateList {
+ return rememberSaveable(saver = nav2ComposeBackStackSaver()) {
+ mutableStateListOf(Home)
+ }
+}
+
+private fun nav2ComposeBackStackSaver() =
+ listSaver, Bundle>(
+ save = { stack -> stack.map { destination -> destination.toSavedState() } },
+ restore = { savedDestinations ->
+ mutableStateListOf().apply {
+ addAll(
+ savedDestinations.map { savedDestination -> savedDestination.toNav2ComposeDestination() }
+ )
+ if (isEmpty()) {
+ add(Home)
+ }
+ }
+ },
+ )
+
+private fun List.toComposeBackStackText(): String =
+ joinToString(" -> ") { destination -> destination.backStackRoute() }
+
+private sealed class Nav2ComposeDestination(
+ val routeName: String,
+ val route: String,
+ val arguments: Map = emptyMap(),
+) {
+
+ data object Home : Nav2ComposeDestination(Nav2RouteNames.HOME, Nav2RouteNames.HOME)
+
+ data object ProductList :
+ Nav2ComposeDestination(Nav2RouteNames.PRODUCT_LIST, Nav2RouteNames.PRODUCT_LIST)
+
+ data class ProductDetail(
+ val productId: String,
+ val source: String,
+ val campaign: String = "",
+ ) :
+ Nav2ComposeDestination(
+ routeName = Nav2RouteNames.PRODUCT_DETAIL,
+ route =
+ "${Nav2RouteNames.PRODUCT_DETAIL}/$productId/$source" +
+ if (campaign.isNotEmpty()) "?${Nav2Args.CAMPAIGN}=$campaign" else "",
+ arguments =
+ mapOf(
+ Nav2Args.PRODUCT_ID to productId,
+ Nav2Args.SOURCE to source,
+ Nav2Args.CAMPAIGN to campaign,
+ )
+ .filterValues { value -> value.isNotEmpty() },
+ )
+
+ data class Checkout(val productId: String) :
+ Nav2ComposeDestination(
+ routeName = Nav2RouteNames.CHECKOUT,
+ route = "${Nav2RouteNames.CHECKOUT}/$productId",
+ arguments = mapOf(Nav2Args.PRODUCT_ID to productId),
+ )
+
+ data class Confirmation(val orderId: String) :
+ Nav2ComposeDestination(
+ routeName = Nav2RouteNames.CONFIRMATION,
+ route = "${Nav2RouteNames.CONFIRMATION}/$orderId",
+ arguments = mapOf(Nav2Args.ORDER_ID to orderId),
+ )
+
+ data class PromoDialog(val promoId: String) :
+ Nav2ComposeDestination(
+ routeName = Nav2RouteNames.PROMO_DIALOG,
+ route = "${Nav2RouteNames.PROMO_DIALOG}/$promoId",
+ arguments = mapOf(Nav2Args.PROMO_ID to promoId),
+ )
+
+ fun displayRoute(): String {
+ return Nav2RouteSpecs.get(routeName).displayRoute(arguments)
+ }
+
+ fun backStackRoute(): String = "/$routeName"
+
+ fun toSavedState(): Bundle =
+ Bundle().apply {
+ when (this@Nav2ComposeDestination) {
+ Home -> putString("type", "home")
+ ProductList -> putString("type", "product_list")
+ is ProductDetail -> {
+ putString("type", "product_detail")
+ putString(Nav2Args.PRODUCT_ID, productId)
+ putString(Nav2Args.SOURCE, source)
+ putString(Nav2Args.CAMPAIGN, campaign)
+ }
+ is Checkout -> {
+ putString("type", "checkout")
+ putString(Nav2Args.PRODUCT_ID, productId)
+ }
+ is Confirmation -> {
+ putString("type", "confirmation")
+ putString(Nav2Args.ORDER_ID, orderId)
+ }
+ is PromoDialog -> {
+ putString("type", "promo_dialog")
+ putString(Nav2Args.PROMO_ID, promoId)
+ }
+ }
+ }
+
+ companion object {
+ const val PRODUCT_DETAIL_ROUTE =
+ Nav2RouteNames.PRODUCT_DETAIL +
+ "/{" +
+ Nav2Args.PRODUCT_ID +
+ "}/{" +
+ Nav2Args.SOURCE +
+ "}?" +
+ Nav2Args.CAMPAIGN +
+ "={" +
+ Nav2Args.CAMPAIGN +
+ "}"
+ const val CHECKOUT_ROUTE = Nav2RouteNames.CHECKOUT + "/{" + Nav2Args.PRODUCT_ID + "}"
+ const val CONFIRMATION_ROUTE = Nav2RouteNames.CONFIRMATION + "/{" + Nav2Args.ORDER_ID + "}"
+ const val PROMO_DIALOG_ROUTE = Nav2RouteNames.PROMO_DIALOG + "/{" + Nav2Args.PROMO_ID + "}"
+ }
+}
+
+private fun Bundle.toNav2ComposeDestination(): Nav2ComposeDestination {
+ return when (getString("type")) {
+ "home" -> Home
+ "product_list" -> ProductList
+ "product_detail" ->
+ ProductDetail(
+ productId = requireNotNull(getString(Nav2Args.PRODUCT_ID)),
+ source = requireNotNull(getString(Nav2Args.SOURCE)),
+ campaign = getString(Nav2Args.CAMPAIGN).orEmpty(),
+ )
+ "checkout" -> Checkout(productId = requireNotNull(getString(Nav2Args.PRODUCT_ID)))
+ "confirmation" -> Confirmation(orderId = requireNotNull(getString(Nav2Args.ORDER_ID)))
+ "promo_dialog" -> PromoDialog(promoId = requireNotNull(getString(Nav2Args.PROMO_ID)))
+ else -> Home
+ }
+}
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ContentHosts.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ContentHosts.kt
new file mode 100644
index 00000000000..38a85b77a82
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ContentHosts.kt
@@ -0,0 +1,77 @@
+package io.sentry.samples.android.navigation
+
+import android.content.Context
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import androidx.compose.ui.platform.ComposeView
+
+internal class Nav2ContentHosts(
+ context: Context,
+ navHostId: Int,
+ private val createComposeContent: () -> ComposeView,
+ private val createPerformanceContent: () -> ComposeView,
+) {
+
+ private val contentContainer = FrameLayout(context)
+ private val fragmentHostView =
+ FrameLayout(context).apply {
+ id = navHostId
+ contentContainer.addView(this, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
+ }
+ private var composeNavHostView: ComposeView? = null
+ private var performanceView: ComposeView? = null
+
+ val view: View = contentContainer.apply {
+ layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)
+ }
+
+ fun showFragments() {
+ fragmentHostView.visibility = View.VISIBLE
+ removeComposeNavHostView()
+ removePerformanceView()
+ }
+
+ fun showCompose() {
+ fragmentHostView.visibility = View.GONE
+ removePerformanceView()
+
+ if (composeNavHostView == null) {
+ composeNavHostView =
+ createComposeContent().also { view ->
+ contentContainer.addView(view, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
+ }
+ }
+ }
+
+ fun showPerformance() {
+ fragmentHostView.visibility = View.GONE
+ removeComposeNavHostView()
+
+ if (performanceView == null) {
+ performanceView =
+ createPerformanceContent().also { view ->
+ contentContainer.addView(view, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
+ }
+ }
+ }
+
+ fun removeComposeNavHostView() {
+ composeNavHostView?.let { view ->
+ disposeAndRemoveHostView(view)
+ composeNavHostView = null
+ }
+ }
+
+ fun removePerformanceView() {
+ performanceView?.let { view ->
+ disposeAndRemoveHostView(view)
+ performanceView = null
+ }
+ }
+
+ private fun disposeAndRemoveHostView(view: ComposeView) {
+ view.disposeComposition()
+ contentContainer.removeView(view)
+ }
+}
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ModalFragments.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ModalFragments.kt
new file mode 100644
index 00000000000..b4f1326204e
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ModalFragments.kt
@@ -0,0 +1,242 @@
+package io.sentry.samples.android.navigation
+
+import android.app.Dialog
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.view.Gravity
+import android.view.View
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.core.view.setPadding
+import androidx.fragment.app.DialogFragment
+import io.sentry.samples.android.R
+
+class Nav2PromoDialogFragment : DialogFragment() {
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val activity = requireActivity() as Nav2Activity
+ activity.tagCurrentScenarioOnTransaction()
+ val promoId = requireArguments().getString(Nav2Args.PROMO_ID).orEmpty()
+
+ return Dialog(requireContext()).apply {
+ window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+ setContentView(
+ LinearLayout(requireContext()).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(20.dp(requireContext()))
+ addView(promoDialogContent(activity, promoId))
+ }
+ )
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setLayout(MATCH_PARENT, WRAP_CONTENT)
+ }
+
+ private fun promoDialogContent(activity: Nav2Activity, promoId: String): View =
+ LinearLayout(requireContext()).apply {
+ val routeSpec = Nav2RouteSpecs.promoDialog
+ orientation = LinearLayout.VERTICAL
+ background = roundedSurface(context, topCornersOnly = false)
+ setPadding(24.dp(context))
+ addView(sectionLabel(context, "Navigation destination"))
+ addView(titleText(context, routeSpec.title))
+ routeSpec.description?.let { addView(bodyText(context, it)) }
+ routeSpec.displayArguments(mapOf(Nav2Args.PROMO_ID to promoId)).firstOrNull()?.let {
+ (label, value) ->
+ addView(argumentPill(context, "$label=$value"))
+ }
+ addView(spacer(context, height = 16.dp(context)))
+ addView(
+ primaryButton(context, R.id.nav2_modal_capture_exception, "Exception") {
+ activity.captureSampleException("Nav2")
+ }
+ )
+ addView(spacer(context, height = 10.dp(context)))
+ addView(
+ secondaryButton(context, R.id.nav2_modal_crash_app, "Crash App") {
+ activity.showCrashConfirmation("Nav2")
+ }
+ )
+ addView(spacer(context, height = 10.dp(context)))
+ addView(
+ LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.END
+ layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ addView(
+ quietButton(context, R.id.nav2_modal_dismiss, "Dismiss") { activity.navigateBack() }
+ )
+ }
+ )
+ }
+}
+
+class Nav2ShareSheetFragment : DialogFragment() {
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val activity = requireActivity() as Nav2Activity
+ activity.tagCurrentScenarioOnTransaction()
+ val productId = requireArguments().getString(Nav2Args.PRODUCT_ID).orEmpty()
+
+ return Dialog(requireContext()).apply {
+ window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+ setContentView(
+ LinearLayout(requireContext()).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(12.dp(requireContext()), 0, 12.dp(requireContext()), 12.dp(requireContext()))
+ addView(shareSheetContent(activity, productId))
+ }
+ )
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.apply {
+ setLayout(MATCH_PARENT, WRAP_CONTENT)
+ setGravity(Gravity.BOTTOM)
+ }
+ }
+
+ private fun shareSheetContent(activity: Nav2Activity, productId: String): View =
+ LinearLayout(requireContext()).apply {
+ val routeSpec = Nav2RouteSpecs.shareSheet
+ orientation = LinearLayout.VERTICAL
+ setPadding(24.dp(context))
+ background = roundedSurface(context, topCornersOnly = true)
+ addView(sectionLabel(context, "Overlay surface"))
+ addView(titleText(context, routeSpec.title))
+ routeSpec.description?.let { addView(bodyText(context, it)) }
+ routeSpec.displayArguments(mapOf(Nav2Args.PRODUCT_ID to productId)).firstOrNull()?.let {
+ (label, value) ->
+ addView(argumentPill(context, "$label=$value"))
+ }
+ addView(spacer(context, height = 16.dp(context)))
+ addView(
+ primaryButton(context, R.id.nav2_modal_capture_exception, "Capture Exception") {
+ activity.captureSampleException("Nav2")
+ }
+ )
+ addView(spacer(context, height = 10.dp(context)))
+ addView(
+ secondaryButton(context, R.id.nav2_modal_crash_app, "Crash App") {
+ activity.showCrashConfirmation("Nav2")
+ }
+ )
+ addView(spacer(context, height = 10.dp(context)))
+ addView(
+ primaryButton(context, R.id.nav2_share_sheet_done, "Done") { activity.navigateBack() }
+ .apply {
+ backgroundTintList = ColorStateList.valueOf(0xFFE8E1F7.toInt())
+ setTextColor(0xFF4E4569.toInt())
+ }
+ )
+ }
+}
+
+private fun sectionLabel(context: Context, textValue: String): TextView =
+ TextView(context).apply {
+ text = textValue.uppercase()
+ textSize = 11f
+ setTypeface(null, Typeface.BOLD)
+ letterSpacing = 0.08f
+ setTextColor(color(context, R.color.colorPrimary))
+ setPadding(0, 0, 0, 10.dp(context))
+ }
+
+private fun titleText(context: Context, textValue: String): TextView =
+ TextView(context).apply {
+ text = textValue
+ textSize = 26f
+ setTypeface(null, Typeface.BOLD)
+ setTextColor(color(context, android.R.color.black))
+ setPadding(0, 0, 0, 8.dp(context))
+ }
+
+private fun bodyText(context: Context, textValue: String): TextView =
+ TextView(context).apply {
+ text = textValue
+ textSize = 15f
+ setTextColor(0xFF5E5873.toInt())
+ setLineSpacing(0f, 1.12f)
+ setPadding(0, 0, 0, 14.dp(context))
+ }
+
+private fun argumentPill(context: Context, textValue: String): TextView =
+ TextView(context).apply {
+ text = textValue
+ textSize = 13f
+ setTypeface(null, Typeface.BOLD)
+ setTextColor(0xFF4E4569.toInt())
+ background =
+ GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ setColor(0xFFF1E8FF.toInt())
+ cornerRadius = 14.dp(context).toFloat()
+ }
+ setPadding(12.dp(context), 8.dp(context), 12.dp(context), 8.dp(context))
+ layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ }
+
+private fun primaryButton(context: Context, id: Int, label: String, onClick: () -> Unit): Button =
+ Button(context).apply {
+ this.id = id
+ text = label
+ isAllCaps = false
+ backgroundTintList = ColorStateList.valueOf(color(context, R.color.colorPrimary))
+ setTextColor(Color.WHITE)
+ setOnClickListener { onClick() }
+ layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ }
+
+private fun secondaryButton(context: Context, id: Int, label: String, onClick: () -> Unit): Button =
+ Button(context).apply {
+ this.id = id
+ text = label
+ isAllCaps = false
+ backgroundTintList = ColorStateList.valueOf(color(context, R.color.colorAccent))
+ setTextColor(Color.WHITE)
+ setOnClickListener { onClick() }
+ layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ }
+
+private fun quietButton(context: Context, id: Int, label: String, onClick: () -> Unit): Button =
+ Button(context, null, android.R.attr.borderlessButtonStyle).apply {
+ this.id = id
+ text = label
+ isAllCaps = false
+ setTextColor(0xFF756E89.toInt())
+ setOnClickListener { onClick() }
+ }
+
+private fun roundedSurface(context: Context, topCornersOnly: Boolean): GradientDrawable {
+ return GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ setColor(color(context, android.R.color.white))
+ val radius = 28.dp(context).toFloat()
+ if (topCornersOnly) {
+ cornerRadii = floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f)
+ } else {
+ cornerRadius = radius
+ }
+ }
+}
+
+private fun spacer(context: Context, height: Int): View =
+ View(context).apply {
+ layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, height)
+ }
+
+private fun color(context: Context, id: Int): Int = context.getColor(id)
+
+private fun Int.dp(context: Context): Int =
+ (this * context.resources.displayMetrics.density).toInt()
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteFragment.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteFragment.kt
new file mode 100644
index 00000000000..f1bb141a39b
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteFragment.kt
@@ -0,0 +1,227 @@
+package io.sentry.samples.android.navigation
+
+import android.graphics.Typeface
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.core.view.setPadding
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import io.sentry.samples.android.R
+import kotlinx.coroutines.launch
+
+class Nav2RouteFragment : Fragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ val activity = requireActivity() as Nav2Activity
+ val routeName = requireArguments().getString(Nav2Args.ROUTE_NAME).orEmpty()
+ val routeSpec = Nav2RouteSpecs.get(routeName)
+
+ return when (routeName) {
+ Nav2RouteNames.LANDING -> routeLayout(routeSpec)
+
+ Nav2RouteNames.HOME ->
+ routeLayout(
+ routeSpec,
+ buttons =
+ listOf(
+ RouteButton(R.id.nav2_fragment_browse_products, "Browse Products") {
+ activity.navigateTo(Nav2Destination.ProductList)
+ }
+ ),
+ )
+
+ Nav2RouteNames.DEEP_LINK ->
+ routeLayout(
+ routeSpec,
+ buttons =
+ listOf(
+ RouteButton(R.id.nav2_fragment_open_deep_link, "Go to deep link destination") {
+ activity.openSyntheticProductDeepLink()
+ }
+ ),
+ )
+
+ Nav2RouteNames.PRODUCT_LIST ->
+ routeLayout(
+ routeSpec,
+ buttons =
+ listOf(
+ RouteButton(R.id.nav2_fragment_open_product_42, "Open Product 42") {
+ activity.navigateTo(
+ Nav2Destination.ProductDetail("42", "product-list", "summer-sale")
+ )
+ },
+ RouteButton(R.id.nav2_fragment_open_product_7, "Open Product 7") {
+ activity.navigateTo(Nav2Destination.ProductDetail("7", "product-list"))
+ },
+ ),
+ )
+
+ Nav2RouteNames.PRODUCT_DETAIL -> productDetailLayout(activity)
+
+ Nav2RouteNames.CHECKOUT -> checkoutLayout(activity)
+
+ Nav2RouteNames.CONFIRMATION -> confirmationLayout(activity)
+
+ else ->
+ routeLayout(
+ Nav2RouteSpec(routeName = routeName, title = "Unknown Route", description = routeName)
+ )
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ val activity = requireActivity() as Nav2Activity
+ val routeName = requireArguments().getString(Nav2Args.ROUTE_NAME).orEmpty()
+
+ activity.tagCurrentScenarioOnTransaction()
+
+ if (routeName == Nav2RouteNames.LANDING) {
+ view.post { activity.cancelCurrentUiLoadTransaction() }
+ return
+ }
+
+ if (routeName == Nav2RouteNames.PRODUCT_DETAIL) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ recordSimulatedBackgroundSpan(routeName)
+ }
+ }
+
+ activity.runRouteWorkAction(routeName)
+ }
+
+ private fun productDetailLayout(activity: Nav2Activity): View {
+ val arguments = requireArguments()
+ val productId = arguments.getString(Nav2Args.PRODUCT_ID).orEmpty()
+ val source = arguments.getString(Nav2Args.SOURCE).orEmpty()
+ val scenario =
+ if (source == "deep-link") {
+ Nav2Scenario.DEEP_LINK
+ } else {
+ Nav2Scenario.FRAGMENTS
+ }
+
+ val routeSpec = Nav2RouteSpecs.productDetail
+ return routeLayout(
+ routeSpec,
+ arguments = arguments,
+ buttons =
+ listOf(
+ RouteButton(R.id.nav2_fragment_show_promo_dialog, "Show Promo Dialog") {
+ activity.navigateTo(Nav2Destination.PromoDialog("detail-$productId", scenario))
+ },
+ RouteButton(R.id.nav2_fragment_open_share_sheet, "Open Share Sheet") {
+ activity.navigateTo(Nav2Destination.ShareSheet(productId, scenario))
+ },
+ RouteButton(R.id.nav2_fragment_go_to_checkout, "Go to Checkout") {
+ activity.navigateTo(Nav2Destination.Checkout(productId))
+ },
+ ),
+ )
+ }
+
+ private fun checkoutLayout(activity: Nav2Activity): View {
+ val productId = requireArguments().getString(Nav2Args.PRODUCT_ID).orEmpty()
+ val routeSpec = Nav2RouteSpecs.checkout
+ return routeLayout(
+ routeSpec,
+ arguments = requireArguments(),
+ buttons =
+ listOf(
+ RouteButton(R.id.nav2_fragment_complete_order, "Complete Order") {
+ activity.navigateTo(Nav2Destination.Confirmation("order-$productId"))
+ }
+ ),
+ )
+ }
+
+ private fun confirmationLayout(activity: Nav2Activity): View {
+ val routeSpec = Nav2RouteSpecs.confirmation
+ return routeLayout(
+ routeSpec,
+ arguments = requireArguments(),
+ buttons =
+ listOf(
+ RouteButton(R.id.nav2_fragment_reset_backstack, "Reset Backstack") {
+ activity.resetToHome()
+ }
+ ),
+ )
+ }
+
+ private fun routeLayout(
+ routeSpec: Nav2RouteSpec,
+ arguments: Bundle? = null,
+ buttons: List = emptyList(),
+ ): View {
+ val info = routeSpec.displayArguments(arguments)
+ return LinearLayout(requireContext()).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(16.dp)
+ addView(titleText(routeSpec.title))
+ routeSpec.description?.let { addView(bodyText(it)) }
+
+ if (info.isNotEmpty() || buttons.isNotEmpty()) {
+ addView(
+ LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(16.dp)
+ setBackgroundColor(color(android.R.color.darker_gray))
+ info.forEach { (label, value) -> addView(infoRow(label, value)) }
+ buttons.forEach { button -> addView(routeButton(button)) }
+ }
+ )
+ }
+ }
+ }
+
+ private fun titleText(textValue: String): TextView =
+ TextView(requireContext()).apply {
+ text = textValue
+ textSize = 26f
+ setTypeface(null, Typeface.BOLD)
+ setTextColor(color(android.R.color.black))
+ setPadding(0, 0, 0, 12.dp)
+ }
+
+ private fun bodyText(textValue: String): TextView =
+ TextView(requireContext()).apply {
+ text = textValue
+ textSize = 15f
+ setTextColor(color(android.R.color.black))
+ setPadding(0, 0, 0, 16.dp)
+ }
+
+ private fun infoRow(label: String, value: String): View =
+ TextView(requireContext()).apply {
+ text = "$label: $value"
+ textSize = 14f
+ setPadding(8.dp)
+ }
+
+ private fun routeButton(button: RouteButton): Button =
+ Button(requireContext()).apply {
+ id = button.id
+ text = button.label
+ isAllCaps = false
+ setOnClickListener { button.onClick() }
+ layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ }
+
+ private data class RouteButton(val id: Int, val label: String, val onClick: () -> Unit)
+
+ private val Int.dp: Int
+ get() = (this * resources.displayMetrics.density).toInt()
+
+ private fun color(id: Int): Int = requireContext().getColor(id)
+}
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteWorkDialog.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteWorkDialog.kt
new file mode 100644
index 00000000000..f6cb37aefa7
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteWorkDialog.kt
@@ -0,0 +1,55 @@
+package io.sentry.samples.android.navigation
+
+import android.content.Context
+import android.graphics.Typeface
+import android.view.View
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+
+internal fun showRouteWorkDialog(
+ context: Context,
+ selectedOptions: Set,
+ onSelectedOptionsChanged: (Set) -> Unit,
+) {
+ val options = RouteWorkOption.entries.toTypedArray()
+ val checkedItems = options.map { it in selectedOptions }.toBooleanArray()
+
+ AlertDialog.Builder(context)
+ .setCustomTitle(routeWorkDialogTitle(context))
+ .setMultiChoiceItems(
+ options.map { it.label }.toTypedArray(),
+ checkedItems,
+ ) { _, which, isChecked ->
+ checkedItems[which] = isChecked
+ }
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ onSelectedOptionsChanged(options.filterIndexed { index, _ -> checkedItems[index] }.toSet())
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+}
+
+private fun routeWorkDialogTitle(context: Context): View =
+ LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(context.dp(24), context.dp(24), context.dp(24), 0)
+ addView(
+ TextView(context).apply {
+ text = "Route work"
+ textSize = 20f
+ setTypeface(null, Typeface.BOLD)
+ setTextColor(context.getColor(android.R.color.black))
+ }
+ )
+ addView(
+ TextView(context).apply {
+ text = "Enable/disable the generation of spans by navigation destinations."
+ textSize = 14f
+ setTextColor(0xFF756E89.toInt())
+ setPadding(0, context.dp(8), 0, 0)
+ }
+ )
+ }
+
+private fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2Routes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2Routes.kt
new file mode 100644
index 00000000000..7b278159d81
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2Routes.kt
@@ -0,0 +1,400 @@
+package io.sentry.samples.android.navigation
+
+import android.os.Bundle
+import android.view.ViewGroup
+import androidx.core.os.bundleOf
+import androidx.navigation.NavDestination
+import io.sentry.Sentry
+import io.sentry.protocol.SentryTransaction
+import io.sentry.samples.android.R
+import kotlin.coroutines.resume
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+internal object Nav2RouteNames {
+
+ const val LANDING = "Landing"
+ const val HOME = "Home"
+ const val PRODUCT_LIST = "ProductList"
+ const val DEEP_LINK = "DeepLink"
+ const val PRODUCT_DETAIL = "ProductDetail"
+ const val CHECKOUT = "Checkout"
+ const val CONFIRMATION = "Confirmation"
+ const val PROMO_DIALOG = "PromoDialog"
+ const val SHARE_SHEET = "ShareSheet"
+}
+
+internal object Nav2Args {
+
+ const val ROUTE_NAME = "route_name"
+ const val PRODUCT_ID = "product_id"
+ const val SOURCE = "source"
+ const val CAMPAIGN = "campaign"
+ const val ORDER_ID = "order_id"
+ const val PROMO_ID = "promo_id"
+ const val SCENARIO = "scenario"
+}
+
+internal data class Nav2DisplayedArgument(val key: String, val label: String = key)
+
+internal data class Nav2RouteSpec(
+ val routeName: String,
+ val title: String,
+ val description: String? = null,
+ val displayedArguments: List = emptyList(),
+)
+
+internal object Nav2RouteSpecs {
+ val landing =
+ Nav2RouteSpec(
+ routeName = Nav2RouteNames.LANDING,
+ title = "Landing",
+ description =
+ "Activity ui.load transactions are configured when the Sentry SDK initializes, so this " +
+ "sample cannot truly disable them at launch time. Instead, we cancel and clear the " +
+ "current ui.load transaction when you land here.",
+ )
+
+ val home =
+ Nav2RouteSpec(
+ routeName = Nav2RouteNames.HOME,
+ title = "Home",
+ description =
+ "Start a product flow, then use the Sentry UI to inspect route " +
+ "transactions, breadcrumbs, and screen tracking.",
+ )
+
+ val deepLink =
+ Nav2RouteSpec(
+ routeName = Nav2RouteNames.DEEP_LINK,
+ title = "Deep Link (Fragments)",
+ description =
+ "Simulates opening a fragment deep link that builds a synthetic backstack before landing " +
+ "on a detail destination.",
+ )
+
+ val productList =
+ Nav2RouteSpec(
+ routeName = Nav2RouteNames.PRODUCT_LIST,
+ title = "Product List",
+ description = "This route starts the product journey.",
+ )
+
+ val productDetail =
+ Nav2RouteSpec(
+ routeName = Nav2RouteNames.PRODUCT_DETAIL,
+ title = "Product Detail",
+ description = "",
+ displayedArguments =
+ listOf(
+ Nav2DisplayedArgument(Nav2Args.PRODUCT_ID, "productId"),
+ Nav2DisplayedArgument(Nav2Args.SOURCE),
+ Nav2DisplayedArgument(Nav2Args.CAMPAIGN),
+ ),
+ )
+
+ val checkout =
+ Nav2RouteSpec(
+ routeName = Nav2RouteNames.CHECKOUT,
+ title = "Checkout",
+ description = "",
+ displayedArguments = listOf(Nav2DisplayedArgument(Nav2Args.PRODUCT_ID, "productId")),
+ )
+
+ val confirmation =
+ Nav2RouteSpec(
+ routeName = Nav2RouteNames.CONFIRMATION,
+ title = "Confirmation",
+ description = "End of the product flow.",
+ displayedArguments = listOf(Nav2DisplayedArgument(Nav2Args.ORDER_ID, "orderId")),
+ )
+
+ val promoDialog =
+ Nav2RouteSpec(
+ routeName = Nav2RouteNames.PROMO_DIALOG,
+ title = "Promo Dialog",
+ description =
+ "This modal is a real Nav destination, so its breadcrumbs and route transaction should " +
+ "stand on their own.",
+ displayedArguments = listOf(Nav2DisplayedArgument(Nav2Args.PROMO_ID, "promoId")),
+ )
+
+ val shareSheet =
+ Nav2RouteSpec(
+ routeName = Nav2RouteNames.SHARE_SHEET,
+ title = "Share Sheet",
+ description =
+ "This sheet stays attached to the current route so you can compare an overlay against a " +
+ "real destination.",
+ displayedArguments = listOf(Nav2DisplayedArgument(Nav2Args.PRODUCT_ID, "productId")),
+ )
+
+ fun get(routeName: String): Nav2RouteSpec =
+ when (routeName) {
+ Nav2RouteNames.LANDING -> landing
+ Nav2RouteNames.HOME -> home
+ Nav2RouteNames.DEEP_LINK -> deepLink
+ Nav2RouteNames.PRODUCT_LIST -> productList
+ Nav2RouteNames.PRODUCT_DETAIL -> productDetail
+ Nav2RouteNames.CHECKOUT -> checkout
+ Nav2RouteNames.CONFIRMATION -> confirmation
+ Nav2RouteNames.PROMO_DIALOG -> promoDialog
+ Nav2RouteNames.SHARE_SHEET -> shareSheet
+ else -> Nav2RouteSpec(routeName = routeName, title = routeName)
+ }
+}
+
+internal sealed class Nav2Destination(
+ val id: Int,
+ val routeName: String,
+ val arguments: Bundle = Bundle.EMPTY,
+) {
+
+ data object Landing : Nav2Destination(R.id.nav2_landing, Nav2RouteNames.LANDING)
+
+ data object Home : Nav2Destination(R.id.nav2_home, Nav2RouteNames.HOME)
+
+ data object ProductList : Nav2Destination(R.id.nav2_product_list, Nav2RouteNames.PRODUCT_LIST)
+
+ data object DeepLink : Nav2Destination(R.id.nav2_deep_link, Nav2RouteNames.DEEP_LINK)
+
+ data class ProductDetail(
+ val productId: String,
+ val source: String,
+ val campaign: String = "",
+ ) :
+ Nav2Destination(
+ R.id.nav2_product_detail,
+ Nav2RouteNames.PRODUCT_DETAIL,
+ bundleOf(
+ Nav2Args.PRODUCT_ID to productId,
+ Nav2Args.SOURCE to source,
+ Nav2Args.CAMPAIGN to campaign,
+ ),
+ )
+
+ data class Checkout(val productId: String) :
+ Nav2Destination(
+ R.id.nav2_checkout,
+ Nav2RouteNames.CHECKOUT,
+ bundleOf(Nav2Args.PRODUCT_ID to productId),
+ )
+
+ data class Confirmation(val orderId: String) :
+ Nav2Destination(
+ R.id.nav2_confirmation,
+ Nav2RouteNames.CONFIRMATION,
+ bundleOf(Nav2Args.ORDER_ID to orderId),
+ )
+
+ data class PromoDialog(val promoId: String, val scenario: Nav2Scenario) :
+ Nav2Destination(
+ R.id.nav2_promo_dialog,
+ Nav2RouteNames.PROMO_DIALOG,
+ bundleOf(Nav2Args.PROMO_ID to promoId, Nav2Args.SCENARIO to scenario.name),
+ )
+
+ data class ShareSheet(val productId: String, val scenario: Nav2Scenario) :
+ Nav2Destination(
+ R.id.nav2_share_sheet,
+ Nav2RouteNames.SHARE_SHEET,
+ bundleOf(Nav2Args.PRODUCT_ID to productId, Nav2Args.SCENARIO to scenario.name),
+ )
+}
+
+internal enum class Nav2Scenario(val label: String, val showTab: Boolean = true) {
+ LANDING(Nav2RouteNames.LANDING, showTab = false),
+ COMPOSE("Compose"),
+ FRAGMENTS("Fragments"),
+ DEEP_LINK("Deep Link (Fragments)"),
+ PERFORMANCE("Performance"),
+}
+
+/**
+ * Optional work the Nav2 sample app can perform when a route becomes active / when navigating to a
+ * new destination.
+ */
+internal enum class RouteWorkOption(val label: String, val tagName: String) {
+
+ /**
+ * Executes an HTTP request in the new nav destination.
+ *
+ * For composables, the request is executed in a composable *Effect.
+ */
+ HTTP_REQUEST("HTTP request", "http_request"),
+
+ /**
+ * Generates a child span in the new nav destination.
+ *
+ * For composables, the span is generated directly in the composable body (i.e., during
+ * (re)composition), rather than via an *Effect. That's bad practice generally, but it lets us
+ * test whether our nav transactions can pick up work done in the destination during composition.
+ */
+ MANUAL_CHILD_SPAN("Manual child span", "manual_child_span"),
+}
+
+internal fun Nav2Destination.routeSpec(): Nav2RouteSpec = Nav2RouteSpecs.get(routeName)
+
+internal fun Nav2Destination.displayRoute(): String = routeSpec().displayRoute(arguments)
+
+internal fun Nav2RouteSpec.displayArguments(arguments: Bundle?): List> =
+ displayedArguments.mapNotNull { argument ->
+ arguments
+ ?.getString(argument.key)
+ ?.takeIf { value -> value.isNotEmpty() }
+ ?.let { value -> argument.label to value }
+ }
+
+internal fun Nav2RouteSpec.displayArguments(
+ arguments: Map
+): List> = displayedArguments.mapNotNull { argument ->
+ arguments[argument.key]
+ ?.toString()
+ ?.takeIf { value -> value.isNotEmpty() }
+ ?.let { value -> argument.label to value }
+}
+
+internal fun Nav2RouteSpec.displayRoute(arguments: Bundle?): String =
+ displayRoute(displayArguments(arguments))
+
+internal fun Nav2RouteSpec.displayRoute(arguments: Map): String =
+ displayRoute(displayArguments(arguments))
+
+private fun Nav2RouteSpec.displayRoute(displayArguments: List>): String =
+ if (displayArguments.isEmpty()) {
+ "/$routeName"
+ } else {
+ "/$routeName { ${displayArguments.toDisplayString()} }"
+ }
+
+internal fun List>.toDisplayString(): String =
+ joinToString(", ") { (label, value) -> "$label=$value" }
+
+internal fun MutableList.resetTo(destination: Nav2Destination) {
+ clear()
+ add(destination)
+}
+
+internal fun Nav2Destination.matches(destination: NavDestination, arguments: Bundle?): Boolean =
+ id == destination.id && argumentsMatch(arguments)
+
+private fun Nav2Destination.argumentsMatch(arguments: Bundle?): Boolean =
+ when (this) {
+ Nav2Destination.Home,
+ Nav2Destination.Landing,
+ Nav2Destination.ProductList,
+ Nav2Destination.DeepLink -> true
+ is Nav2Destination.ProductDetail ->
+ arguments?.getString(Nav2Args.PRODUCT_ID) == productId &&
+ arguments.getString(Nav2Args.SOURCE) == source &&
+ arguments.getString(Nav2Args.CAMPAIGN).orEmpty() == campaign
+ is Nav2Destination.Checkout -> arguments?.getString(Nav2Args.PRODUCT_ID) == productId
+ is Nav2Destination.Confirmation -> arguments?.getString(Nav2Args.ORDER_ID) == orderId
+ is Nav2Destination.PromoDialog -> arguments?.getString(Nav2Args.PROMO_ID) == promoId
+ is Nav2Destination.ShareSheet -> arguments?.getString(Nav2Args.PRODUCT_ID) == productId
+ }
+
+internal fun NavDestination.toNav2Destination(arguments: Bundle?): Nav2Destination? =
+ when (id) {
+ R.id.nav2_landing -> Nav2Destination.Landing
+ R.id.nav2_home -> Nav2Destination.Home
+ R.id.nav2_product_list -> Nav2Destination.ProductList
+ R.id.nav2_deep_link -> Nav2Destination.DeepLink
+ R.id.nav2_product_detail ->
+ Nav2Destination.ProductDetail(
+ productId = arguments?.getString(Nav2Args.PRODUCT_ID).orEmpty(),
+ source = arguments?.getString(Nav2Args.SOURCE).orEmpty(),
+ campaign = arguments?.getString(Nav2Args.CAMPAIGN).orEmpty(),
+ )
+ R.id.nav2_checkout ->
+ Nav2Destination.Checkout(arguments?.getString(Nav2Args.PRODUCT_ID).orEmpty())
+ R.id.nav2_confirmation ->
+ Nav2Destination.Confirmation(arguments?.getString(Nav2Args.ORDER_ID).orEmpty())
+ R.id.nav2_promo_dialog ->
+ Nav2Destination.PromoDialog(
+ promoId = arguments?.getString(Nav2Args.PROMO_ID).orEmpty(),
+ scenario =
+ arguments?.getString(Nav2Args.SCENARIO).orEmpty().toNav2Scenario()
+ ?: Nav2Scenario.FRAGMENTS,
+ )
+ R.id.nav2_share_sheet ->
+ Nav2Destination.ShareSheet(
+ productId = arguments?.getString(Nav2Args.PRODUCT_ID).orEmpty(),
+ scenario =
+ arguments?.getString(Nav2Args.SCENARIO).orEmpty().toNav2Scenario()
+ ?: Nav2Scenario.FRAGMENTS,
+ )
+ else -> null
+ }
+
+internal fun NavDestination.routeName(): String = routeNameOrNull() ?: Nav2RouteNames.HOME
+
+private fun NavDestination.routeNameOrNull(): String? = route ?: label?.toString()?.replace(" ", "")
+
+private fun String.toNav2Scenario(): Nav2Scenario? =
+ Nav2Scenario.entries.firstOrNull { scenario -> scenario.name == this }
+
+internal fun recordManualChildSpan(routeName: String) {
+ val span =
+ Sentry.getSpan()
+ ?.startChild(
+ "test.navigation.manual_span",
+ "Nav2 /$routeName manual span",
+ )
+ span?.setData("sample.manual_span", true)
+ span?.finish()
+}
+
+internal fun tagCurrentNav2Scenario(scenario: Nav2Scenario) {
+ Sentry.getSpan()?.setTag(NAV2_SCENARIO_TAG, scenario.label)
+}
+
+internal fun SentryTransaction.nav2ScenarioLabel(): String =
+ getTag(NAV2_SCENARIO_TAG) ?: UNKNOWN_NAV2_SCENARIO_LABEL
+
+internal suspend fun recordSimulatedBackgroundSpan(routeName: String) {
+ val parentSpan = Sentry.getSpan()
+ suspendCancellableCoroutine { continuation ->
+ val worker = Thread {
+ val span =
+ parentSpan?.startChild(
+ "test.navigation.background_work",
+ "Nav2 /$routeName background work",
+ )
+ span?.setData("sample.background_work", true)
+ try {
+ Thread.sleep(BACKGROUND_WORK_MILLIS)
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ } finally {
+ span?.finish()
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+ }
+ continuation.invokeOnCancellation { worker.interrupt() }
+ worker.start()
+ }
+}
+
+internal fun tagNav2SampleAction(action: String, route: String) {
+ val span = Sentry.getSpan() ?: return
+ span.setTag("sample_action", "nav2_$action")
+ span.setTag("sample_nav2_route", route)
+}
+
+internal fun MutableList.popTrackedBackStack(popBackStack: () -> Boolean): Boolean {
+ val popped = popBackStack()
+ if (popped && size > 1) {
+ removeAt(lastIndex)
+ }
+ return popped
+}
+
+internal const val SENTRY_FLUSH_TIMEOUT_MILLIS = 5000L
+internal const val BACKGROUND_WORK_MILLIS = 1000L
+internal const val NAV2_SCENARIO_TAG = "sample_nav2_scenario"
+internal const val UNKNOWN_NAV2_SCENARIO_LABEL = "Unknown"
+
+internal const val MATCH_PARENT = ViewGroup.LayoutParams.MATCH_PARENT
+internal const val WRAP_CONTENT = ViewGroup.LayoutParams.WRAP_CONTENT
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SampleConfig.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SampleConfig.kt
new file mode 100644
index 00000000000..064deeb0895
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SampleConfig.kt
@@ -0,0 +1,142 @@
+package io.sentry.samples.android.navigation
+
+import android.content.Context
+import android.content.Intent
+import io.sentry.Sentry
+
+internal data class Nav2SampleConfig(
+ val enableNavigationTransactions: Boolean = true,
+ val enableNavigationBreadcrumbs: Boolean = true,
+ val enableScreenTracking: Boolean = true,
+ val enableActivityUiLoadTransaction: Boolean = false,
+ val enableUserInteractionTransactions: Boolean = false,
+ val enableUserInteractionBreadcrumbs: Boolean = false,
+)
+
+internal val Nav2SampleConfig.hasOnlyActivityUiLoadTransactions: Boolean
+ get() =
+ enableActivityUiLoadTransaction &&
+ !enableNavigationTransactions &&
+ !enableUserInteractionTransactions
+
+internal data class Nav2SampleConfigSnapshot(
+ val enableScreenTracking: Boolean,
+ val enableUserInteractionTransactions: Boolean,
+ val enableUserInteractionBreadcrumbs: Boolean,
+)
+
+internal fun Nav2SampleConfig.applyToCurrentOptions() {
+ applyNav2SampleOptions(
+ enableScreenTracking = enableScreenTracking,
+ enableUserInteractionTransactions = enableUserInteractionTransactions,
+ enableUserInteractionBreadcrumbs = enableUserInteractionBreadcrumbs,
+ )
+}
+
+internal fun Nav2SampleConfigSnapshot.applyToCurrentOptions() {
+ applyNav2SampleOptions(
+ enableScreenTracking = enableScreenTracking,
+ enableUserInteractionTransactions = enableUserInteractionTransactions,
+ enableUserInteractionBreadcrumbs = enableUserInteractionBreadcrumbs,
+ )
+}
+
+private fun applyNav2SampleOptions(
+ enableScreenTracking: Boolean,
+ enableUserInteractionTransactions: Boolean,
+ enableUserInteractionBreadcrumbs: Boolean,
+) {
+ val options = Sentry.getCurrentScopes().options
+ options.setEnableScreenTracking(enableScreenTracking)
+ options.setEnableUserInteractionTracing(enableUserInteractionTransactions)
+ options.setEnableUserInteractionBreadcrumbs(enableUserInteractionBreadcrumbs)
+}
+
+internal fun currentNav2SampleConfigSnapshot(): Nav2SampleConfigSnapshot {
+ val options = Sentry.getCurrentScopes().options
+ return Nav2SampleConfigSnapshot(
+ enableScreenTracking = options.isEnableScreenTracking,
+ enableUserInteractionTransactions = options.isEnableUserInteractionTracing,
+ enableUserInteractionBreadcrumbs = options.isEnableUserInteractionBreadcrumbs,
+ )
+}
+
+internal fun Intent.previousNav2SampleConfigSnapshot(
+ fallback: Nav2SampleConfigSnapshot
+): Nav2SampleConfigSnapshot =
+ Nav2SampleConfigSnapshot(
+ enableScreenTracking =
+ getBooleanExtra(EXTRA_PREVIOUS_ENABLE_SCREEN_TRACKING, fallback.enableScreenTracking),
+ enableUserInteractionTransactions =
+ getBooleanExtra(
+ EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_TRANSACTIONS,
+ fallback.enableUserInteractionTransactions,
+ ),
+ enableUserInteractionBreadcrumbs =
+ getBooleanExtra(
+ EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_BREADCRUMBS,
+ fallback.enableUserInteractionBreadcrumbs,
+ ),
+ )
+
+internal fun Intent.nav2SampleConfig(): Nav2SampleConfig =
+ Nav2SampleConfig(
+ enableNavigationTransactions = getBooleanExtra(EXTRA_ENABLE_NAVIGATION_TRANSACTIONS, true),
+ enableNavigationBreadcrumbs = getBooleanExtra(EXTRA_ENABLE_NAVIGATION_BREADCRUMBS, true),
+ enableScreenTracking = getBooleanExtra(EXTRA_ENABLE_SCREEN_TRACKING, true),
+ enableActivityUiLoadTransaction =
+ getBooleanExtra(EXTRA_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION, false),
+ enableUserInteractionTransactions =
+ getBooleanExtra(EXTRA_ENABLE_USER_INTERACTION_TRANSACTIONS, false),
+ enableUserInteractionBreadcrumbs =
+ getBooleanExtra(EXTRA_ENABLE_USER_INTERACTION_BREADCRUMBS, false),
+ )
+
+internal fun Context.nav2LaunchIntent(
+ configuration: Nav2SampleConfig,
+ previousOptions: Nav2SampleConfigSnapshot,
+): Intent =
+ Intent(this, Nav2Activity::class.java)
+ .putExtra(EXTRA_ENABLE_NAVIGATION_TRANSACTIONS, configuration.enableNavigationTransactions)
+ .putExtra(EXTRA_ENABLE_NAVIGATION_BREADCRUMBS, configuration.enableNavigationBreadcrumbs)
+ .putExtra(EXTRA_ENABLE_SCREEN_TRACKING, configuration.enableScreenTracking)
+ .putExtra(
+ EXTRA_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION,
+ configuration.enableActivityUiLoadTransaction,
+ )
+ .putExtra(
+ EXTRA_ENABLE_USER_INTERACTION_TRANSACTIONS,
+ configuration.enableUserInteractionTransactions,
+ )
+ .putExtra(
+ EXTRA_ENABLE_USER_INTERACTION_BREADCRUMBS,
+ configuration.enableUserInteractionBreadcrumbs,
+ )
+ .putExtra(EXTRA_PREVIOUS_ENABLE_SCREEN_TRACKING, previousOptions.enableScreenTracking)
+ .putExtra(
+ EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_TRANSACTIONS,
+ previousOptions.enableUserInteractionTransactions,
+ )
+ .putExtra(
+ EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_BREADCRUMBS,
+ previousOptions.enableUserInteractionBreadcrumbs,
+ )
+
+private const val EXTRA_ENABLE_NAVIGATION_TRANSACTIONS =
+ "io.sentry.samples.android.navigation.enable_navigation_transactions"
+private const val EXTRA_ENABLE_NAVIGATION_BREADCRUMBS =
+ "io.sentry.samples.android.navigation.enable_navigation_breadcrumbs"
+private const val EXTRA_ENABLE_SCREEN_TRACKING =
+ "io.sentry.samples.android.navigation.enable_screen_tracking"
+private const val EXTRA_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION =
+ "io.sentry.samples.android.navigation.enable_activity_ui_load_transaction"
+private const val EXTRA_ENABLE_USER_INTERACTION_TRANSACTIONS =
+ "io.sentry.samples.android.navigation.enable_user_interaction_transactions"
+private const val EXTRA_ENABLE_USER_INTERACTION_BREADCRUMBS =
+ "io.sentry.samples.android.navigation.enable_user_interaction_breadcrumbs"
+private const val EXTRA_PREVIOUS_ENABLE_SCREEN_TRACKING =
+ "io.sentry.samples.android.navigation.previous_enable_screen_tracking"
+private const val EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_TRANSACTIONS =
+ "io.sentry.samples.android.navigation.previous_enable_user_interaction_transactions"
+private const val EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_BREADCRUMBS =
+ "io.sentry.samples.android.navigation.previous_enable_user_interaction_breadcrumbs"
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SetupActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SetupActivity.kt
new file mode 100644
index 00000000000..70810b40149
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SetupActivity.kt
@@ -0,0 +1,297 @@
+package io.sentry.samples.android.navigation
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.HelpOutline
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+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.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * Activity for configuring the developer's experience in the [Nav2Activity], in particular which
+ * transaction types should be active and which nav data the SDK should emit.
+ */
+class Nav2SetupActivity : AppCompatActivity() {
+
+ private var configuration by mutableStateOf(Nav2SampleConfig())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ configuration = savedInstanceState?.nav2SampleConfiguration() ?: configuration
+ setContent {
+ MaterialTheme {
+ Nav2SetupScreen(
+ configuration = configuration,
+ onConfigurationChanged = { updatedConfiguration ->
+ configuration = updatedConfiguration
+ },
+ onLaunch = {
+ val previousOptions = currentNav2SampleConfigSnapshot()
+ configuration.applyToCurrentOptions()
+ startActivity(nav2LaunchIntent(configuration, previousOptions))
+ },
+ )
+ }
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putNav2SampleConfiguration(configuration)
+ }
+}
+
+@Composable
+private fun Nav2SetupScreen(
+ configuration: Nav2SampleConfig,
+ onConfigurationChanged: (Nav2SampleConfig) -> Unit,
+ onLaunch: () -> Unit,
+) {
+ Column(
+ modifier =
+ Modifier.fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .safeDrawingPadding()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = "Navigation 2 Setup",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ Text(
+ text =
+ "Choose which auto-instrumentation features should be active before the Nav2 sample launches.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Nav2SetupSection(title = "Navigation") {
+ Nav2SetupCheckboxRow(
+ label = "Navigation transactions",
+ checked = configuration.enableNavigationTransactions,
+ ) {
+ onConfigurationChanged(configuration.copy(enableNavigationTransactions = it))
+ }
+ Nav2SetupCheckboxRow(
+ label = "Navigation breadcrumbs",
+ checked = configuration.enableNavigationBreadcrumbs,
+ ) {
+ onConfigurationChanged(configuration.copy(enableNavigationBreadcrumbs = it))
+ }
+ Nav2SetupCheckboxRow(
+ label = "Screen tracking",
+ checked = configuration.enableScreenTracking,
+ ) {
+ onConfigurationChanged(configuration.copy(enableScreenTracking = it))
+ }
+ }
+ Nav2SetupSection(title = "Other auto-transactions") {
+ Nav2SetupCheckboxRow(
+ label = "Activity ui.load transaction",
+ checked = configuration.enableActivityUiLoadTransaction,
+ helpText = ACTIVITY_UI_LOAD_HELP_TEXT,
+ ) {
+ onConfigurationChanged(configuration.copy(enableActivityUiLoadTransaction = it))
+ }
+ Nav2SetupCheckboxRow(
+ label = "User interaction transactions",
+ checked = configuration.enableUserInteractionTransactions,
+ ) {
+ onConfigurationChanged(configuration.copy(enableUserInteractionTransactions = it))
+ }
+ }
+ Nav2SetupSection(title = "Other breadcrumbs") {
+ Nav2SetupCheckboxRow(
+ label = "User interaction breadcrumbs",
+ checked = configuration.enableUserInteractionBreadcrumbs,
+ ) {
+ onConfigurationChanged(configuration.copy(enableUserInteractionBreadcrumbs = it))
+ }
+ }
+ Button(onClick = onLaunch, modifier = Modifier.fillMaxWidth()) {
+ Text(
+ "Launch Nav2 Sample",
+ fontSize = 18.sp,
+ )
+ }
+ }
+}
+
+@Composable
+private fun Nav2SetupSection(title: String, content: @Composable () -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ content()
+ }
+ }
+ }
+}
+
+@Composable
+private fun Nav2SetupCheckboxRow(
+ label: String,
+ checked: Boolean,
+ helpText: String? = null,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ val sentryPink = Color(0xFFC85B9C)
+ val rowShape: Shape = RoundedCornerShape(12.dp)
+ val rowBackground by
+ animateColorAsState(
+ targetValue = if (checked) sentryPink.copy(alpha = 0.14f) else Color.Transparent,
+ animationSpec = tween(durationMillis = 220),
+ label = "nav2-setup-toggle-background",
+ )
+ val switchColors =
+ SwitchDefaults.colors(
+ checkedTrackColor = sentryPink,
+ checkedBorderColor = sentryPink,
+ checkedThumbColor = Color.White,
+ )
+ Row(
+ modifier =
+ Modifier.fillMaxWidth()
+ .clip(rowShape)
+ .background(rowBackground, rowShape)
+ .toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange)
+ .defaultMinSize(minHeight = 52.dp)
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Switch(
+ checked = checked,
+ onCheckedChange = null,
+ colors = switchColors,
+ modifier = Modifier.scale(0.8f),
+ )
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(text = label, style = MaterialTheme.typography.titleMedium)
+ if (helpText != null) {
+ Nav2SetupHelpTooltip(helpText)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Nav2SetupHelpTooltip(text: String) {
+ val tooltipState = rememberTooltipState(isPersistent = true)
+ val scope = rememberCoroutineScope()
+
+ LaunchedEffect(tooltipState.isVisible) {
+ if (tooltipState.isVisible) {
+ delay(4000)
+ tooltipState.dismiss()
+ }
+ }
+
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
+ tooltip = { PlainTooltip { Text(text) } },
+ state = tooltipState,
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.HelpOutline,
+ contentDescription = "Activity ui.load transaction help",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier =
+ Modifier.padding(start = 6.dp).size(20.dp).clickable {
+ scope.launch { tooltipState.show() }
+ },
+ )
+ }
+}
+
+private fun Bundle.putNav2SampleConfiguration(configuration: Nav2SampleConfig) {
+ putBoolean("enable_navigation_transactions", configuration.enableNavigationTransactions)
+ putBoolean("enable_navigation_breadcrumbs", configuration.enableNavigationBreadcrumbs)
+ putBoolean("enable_screen_tracking", configuration.enableScreenTracking)
+ putBoolean("enable_activity_ui_load_transaction", configuration.enableActivityUiLoadTransaction)
+ putBoolean(
+ "enable_user_interaction_transactions",
+ configuration.enableUserInteractionTransactions,
+ )
+ putBoolean(
+ "enable_user_interaction_breadcrumbs",
+ configuration.enableUserInteractionBreadcrumbs,
+ )
+}
+
+private fun Bundle.nav2SampleConfiguration(): Nav2SampleConfig =
+ Nav2SampleConfig(
+ enableNavigationTransactions = getBoolean("enable_navigation_transactions", true),
+ enableNavigationBreadcrumbs = getBoolean("enable_navigation_breadcrumbs", true),
+ enableScreenTracking = getBoolean("enable_screen_tracking", true),
+ enableActivityUiLoadTransaction = getBoolean("enable_activity_ui_load_transaction", false),
+ enableUserInteractionTransactions = getBoolean("enable_user_interaction_transactions", false),
+ enableUserInteractionBreadcrumbs = getBoolean("enable_user_interaction_breadcrumbs", false),
+ )
+
+private const val ACTIVITY_UI_LOAD_HELP_TEXT =
+ "The sample simulates disabling ui.load transactions, as actual activity lifecycle " +
+ "tracing is fixed when the SDK initializes."
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TopBar.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TopBar.kt
new file mode 100644
index 00000000000..e740e97c06c
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TopBar.kt
@@ -0,0 +1,248 @@
+package io.sentry.samples.android.navigation
+
+import android.content.Context
+import android.graphics.Typeface
+import android.view.Gravity
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.HorizontalScrollView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountTree
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.core.view.setPadding
+import io.sentry.samples.android.R
+
+/**
+ * A top bar consisting of nav info above tabs for selecting among a variety of [Nav2Scenario]s.
+ *
+ * Displays info about the user's current route and back stack state, and lets the user
+ * enable/disable [RouteWorkOption]s.
+ */
+internal class Nav2TopBar(
+ private val context: Context,
+ private val onTransactionHistoryClick: () -> Unit,
+ private val onRouteWorkSettingsClick: () -> Unit,
+ private val onScenarioClick: (Nav2Scenario) -> Unit,
+) {
+
+ private val topBarStates =
+ mutableMapOf(
+ Nav2Scenario.LANDING to
+ Nav2TopBarState(
+ currentRoute = "/${Nav2RouteNames.LANDING}",
+ backStack = "/${Nav2RouteNames.LANDING}",
+ ),
+ Nav2Scenario.COMPOSE to
+ Nav2TopBarState(
+ currentRoute = "/${Nav2RouteNames.HOME}",
+ backStack = "/${Nav2RouteNames.HOME}",
+ ),
+ Nav2Scenario.FRAGMENTS to
+ Nav2TopBarState(
+ currentRoute = "/${Nav2RouteNames.HOME}",
+ backStack = "/${Nav2RouteNames.HOME}",
+ ),
+ Nav2Scenario.DEEP_LINK to
+ Nav2TopBarState(
+ currentRoute = "/${Nav2RouteNames.DEEP_LINK}",
+ backStack = "/${Nav2RouteNames.DEEP_LINK}",
+ ),
+ Nav2Scenario.PERFORMANCE to
+ Nav2TopBarState(
+ currentRoute = "/${Nav2RouteNames.HOME}",
+ backStack = "/${Nav2RouteNames.HOME}",
+ ),
+ )
+ private val tabViews = mutableMapOf()
+ private val currentRouteText = bodyText()
+ private val navControllerBackStackText = bodyText()
+ private val currentRouteScroll = horizontalTextContainer(currentRouteText)
+ private val navControllerBackStackScroll = horizontalTextContainer(navControllerBackStackText)
+ private var selectedScenario = Nav2Scenario.COMPOSE
+
+ val view: View =
+ LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ addView(createHeader())
+ addView(createTabs())
+ }
+
+ fun update(scenario: Nav2Scenario, currentRoute: String, backStack: String) {
+ topBarStates[scenario] = Nav2TopBarState(currentRoute = currentRoute, backStack = backStack)
+ if (scenario == selectedScenario) {
+ render(scenario)
+ }
+ }
+
+ fun render(scenario: Nav2Scenario) {
+ val topBarState = topBarStates[scenario] ?: return
+ currentRouteText.text = "Current route: ${topBarState.currentRoute}"
+ navControllerBackStackText.text = "NavController back stack: ${topBarState.backStack}"
+ currentRouteScroll.post { currentRouteScroll.scrollTo(0, 0) }
+ navControllerBackStackScroll.post { navControllerBackStackScroll.scrollTo(0, 0) }
+ }
+
+ fun select(scenario: Nav2Scenario) {
+ selectedScenario = scenario
+ render(scenario)
+ tabViews.forEach { (tabScenario, tabView) ->
+ val selected = tabScenario == scenario
+ tabView.label.setTextColor(
+ color(if (selected) R.color.colorPrimary else android.R.color.black)
+ )
+ tabView.label.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL)
+ tabView.indicator.setBackgroundColor(
+ color(if (selected) R.color.colorPrimary else android.R.color.transparent)
+ )
+ }
+ }
+
+ private fun createHeader(): View {
+ return LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(24.dp, 18.dp, 24.dp, 12.dp)
+ addView(
+ LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ addView(
+ TextView(context).apply {
+ text = "Navigation 2"
+ textSize = 20f
+ setTextColor(color(android.R.color.black))
+ layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)
+ }
+ )
+ addView(transactionHistoryIcon())
+ addView(routeWorkSettingsIcon())
+ }
+ )
+ addView(View(context), LinearLayout.LayoutParams(MATCH_PARENT, 12.dp))
+ addView(currentRouteScroll)
+ addView(navControllerBackStackScroll)
+ }
+ }
+
+ private fun createTabs(): View {
+ val tabRow =
+ LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ setPadding(24.dp, 0, 24.dp, 0)
+ }
+
+ Nav2Scenario.entries
+ .filter { it.showTab }
+ .forEach { scenario ->
+ val tabContainer =
+ LinearLayout(context).apply {
+ id = scenario.tabViewId
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER
+ minimumWidth = 120.dp
+ setOnClickListener { onScenarioClick(scenario) }
+ }
+ val textView =
+ TextView(context).apply {
+ text = scenario.label
+ textSize = 14f
+ gravity = Gravity.CENTER
+ setPadding(18.dp, 14.dp, 18.dp, 10.dp)
+ }
+ val indicator =
+ View(context).apply {
+ layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 3.dp)
+ setBackgroundColor(color(android.R.color.transparent))
+ }
+ tabContainer.addView(textView)
+ tabContainer.addView(indicator)
+ tabViews[scenario] = Nav2TabView(textView, indicator)
+ tabRow.addView(tabContainer)
+ }
+
+ return HorizontalScrollView(context).apply {
+ isHorizontalScrollBarEnabled = false
+ addView(tabRow)
+ }
+ }
+
+ private val Nav2Scenario.tabViewId: Int
+ get() =
+ when (this) {
+ Nav2Scenario.LANDING -> R.id.nav2_landing
+ Nav2Scenario.COMPOSE -> R.id.nav2_tab_compose
+ Nav2Scenario.FRAGMENTS -> R.id.nav2_tab_fragments
+ Nav2Scenario.DEEP_LINK -> R.id.nav2_tab_deep_link
+ Nav2Scenario.PERFORMANCE -> R.id.nav2_tab_performance
+ }
+
+ private fun bodyText(): TextView =
+ TextView(context).apply {
+ textSize = 12f
+ setTextColor(color(android.R.color.black))
+ maxLines = 1
+ setHorizontallyScrolling(true)
+ }
+
+ private fun horizontalTextContainer(textView: TextView): HorizontalScrollView =
+ HorizontalScrollView(context).apply {
+ isHorizontalScrollBarEnabled = false
+ layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ addView(textView, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT))
+ }
+
+ private fun routeWorkSettingsIcon(): View =
+ composeIconButton(
+ id = R.id.nav2_route_work_settings,
+ imageVector = Icons.Filled.Settings,
+ contentDescription = "Route work settings",
+ onClick = onRouteWorkSettingsClick,
+ )
+
+ private fun transactionHistoryIcon(): View =
+ composeIconButton(
+ id = R.id.nav2_recent_transactions,
+ imageVector = Icons.Filled.AccountTree,
+ contentDescription = "Recent transactions",
+ onClick = onTransactionHistoryClick,
+ )
+
+ private fun composeIconButton(
+ id: Int,
+ imageVector: ImageVector,
+ contentDescription: String,
+ onClick: () -> Unit,
+ ): View =
+ ComposeView(context).apply {
+ this.id = id
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ MaterialTheme {
+ IconButton(onClick = onClick) {
+ Icon(
+ imageVector = imageVector,
+ contentDescription = contentDescription,
+ tint = Color.Black,
+ )
+ }
+ }
+ }
+ }
+
+ private val Int.dp: Int
+ get() = (this * context.resources.displayMetrics.density).toInt()
+
+ private fun color(id: Int): Int = context.getColor(id)
+}
+
+private data class Nav2TopBarState(val currentRoute: String, val backStack: String)
+
+private data class Nav2TabView(val label: TextView, val indicator: View)
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TransactionHistory.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TransactionHistory.kt
new file mode 100644
index 00000000000..1a911a35285
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TransactionHistory.kt
@@ -0,0 +1,169 @@
+package io.sentry.samples.android.navigation
+
+import android.os.Handler
+import android.os.Looper
+import androidx.compose.runtime.mutableStateListOf
+import io.sentry.Sentry
+import io.sentry.protocol.SentrySpan
+import io.sentry.protocol.SentryTransaction
+import io.sentry.samples.android.SampleBeforeSendTransactionHook
+
+/** State holder backing the [Nav2TransactionHistorySheet]. */
+internal class Nav2TransactionHistory(private val isActive: () -> Boolean) {
+
+ val transactions = mutableStateListOf()
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val transactionListener: (SentryTransaction, String?) -> Unit = { transaction, dsn ->
+ add(transaction, dsn)
+ }
+
+ fun install() {
+ val options = Sentry.getCurrentScopes().options
+ SampleBeforeSendTransactionHook.installIfNeeded(options)
+ SampleBeforeSendTransactionHook.addListener(transactionListener)
+ }
+
+ fun uninstall() {
+ clear()
+ SampleBeforeSendTransactionHook.removeListener(transactionListener)
+ }
+
+ fun clear() {
+ transactions.clear()
+ }
+
+ private fun add(transaction: SentryTransaction, dsn: String?) {
+ if (!isActive()) {
+ return
+ }
+
+ val trace = transaction.toNav2TransactionTrace(dsn)
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ transactions.addMostRecent(trace)
+ } else {
+ mainHandler.post {
+ if (isActive()) {
+ transactions.addMostRecent(trace)
+ }
+ }
+ }
+ }
+}
+
+internal data class Nav2TransactionTrace(
+ val name: String,
+ val operation: String,
+ val eventId: String,
+ val traceId: String,
+ val status: String?,
+ val tab: String,
+ val durationMillis: Double,
+ val sentryUrl: String?,
+ val spans: List,
+)
+
+internal data class Nav2TraceSpan(
+ val spanId: String,
+ val parentSpanId: String?,
+ val operation: String,
+ val description: String?,
+ val startOffsetMillis: Double,
+ val durationMillis: Double,
+ val children: List = emptyList(),
+)
+
+private fun SentryTransaction.toNav2TransactionTrace(dsn: String?): Nav2TransactionTrace {
+ val trace = contexts.trace
+ val startTimestamp = startTimestamp
+ val endTimestamp = timestamp ?: startTimestamp
+ val durationMillis = ((endTimestamp - startTimestamp) * 1_000.0).coerceAtLeast(0.0)
+ val rootSpanId = trace?.spanId?.toString()
+ val traceId = trace?.traceId?.toString().orEmpty()
+ val eventId = eventId?.toString().orEmpty()
+ val rawSpans = spans.map { it.toNav2TraceSpan(startTimestamp) }
+ val spanIds = rawSpans.map { it.spanId }.toSet()
+ val spansByParentId = rawSpans.groupBy { span -> span.parentSpanId }
+ val topLevelSpans =
+ rawSpans
+ .filter { span ->
+ span.parentSpanId == null ||
+ span.parentSpanId == rootSpanId ||
+ span.parentSpanId !in spanIds
+ }
+ .sortedBy { span -> span.startOffsetMillis }
+
+ return Nav2TransactionTrace(
+ name = transaction ?: "",
+ operation = trace?.operation ?: "transaction",
+ eventId = eventId,
+ traceId = traceId,
+ status = status?.name,
+ tab = nav2ScenarioLabel(),
+ durationMillis = durationMillis,
+ sentryUrl = sentryTransactionUrl(dsn, traceId, rootSpanId, eventId, endTimestamp),
+ spans = topLevelSpans.withChildren(spansByParentId),
+ )
+}
+
+private fun MutableList.addMostRecent(transaction: Nav2TransactionTrace) {
+ add(0, transaction)
+ while (size > TRANSACTION_HISTORY_LIMIT) {
+ removeAt(lastIndex)
+ }
+}
+
+private fun SentrySpan.toNav2TraceSpan(transactionStartTimestamp: Double): Nav2TraceSpan =
+ Nav2TraceSpan(
+ spanId = spanId.toString(),
+ parentSpanId = parentSpanId?.toString(),
+ operation = op,
+ description = description,
+ startOffsetMillis = ((startTimestamp - transactionStartTimestamp) * 1_000.0).coerceAtLeast(0.0),
+ durationMillis =
+ (((timestamp ?: startTimestamp) - startTimestamp) * 1_000.0).coerceAtLeast(0.0),
+ )
+
+private fun List.withChildren(
+ spansByParentId: Map>
+): List = map { span -> span.withChildren(spansByParentId) }
+
+private fun Nav2TraceSpan.withChildren(
+ spansByParentId: Map>
+): Nav2TraceSpan =
+ copy(
+ children =
+ spansByParentId[spanId]
+ .orEmpty()
+ .sortedBy { span -> span.startOffsetMillis }
+ .withChildren(spansByParentId)
+ )
+
+private fun sentryTransactionUrl(
+ dsn: String?,
+ traceId: String,
+ rootSpanId: String?,
+ eventId: String,
+ timestampSeconds: Double,
+): String? {
+ val projectId = dsn?.projectIdFromDsn() ?: return null
+ if (traceId.isEmpty() || rootSpanId.isNullOrEmpty() || eventId.isEmpty()) {
+ return null
+ }
+ return "https://$SENTRY_SAMPLE_ORG_SLUG.sentry.io/explore/traces/trace/$traceId/" +
+ "?node=span-$rootSpanId" +
+ "&project=$projectId" +
+ "&source=traces" +
+ "&statsPeriod=14d" +
+ "&targetId=$eventId" +
+ "×tamp=${timestampSeconds.toLong()}"
+}
+
+private fun String.projectIdFromDsn(): String? =
+ substringBefore('?').substringBefore('#').trimEnd('/').substringAfterLast('/').takeIf { projectId
+ ->
+ projectId.isNotEmpty() && projectId.all { it.isDigit() }
+ }
+
+private const val TRANSACTION_HISTORY_LIMIT = 10
+private const val SENTRY_SAMPLE_ORG_SLUG = "sentry-sdks"
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TransactionHistorySheet.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TransactionHistorySheet.kt
new file mode 100644
index 00000000000..657b52dd31f
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TransactionHistorySheet.kt
@@ -0,0 +1,328 @@
+package io.sentry.samples.android.navigation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+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.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import java.util.Locale
+import kotlin.math.roundToInt
+
+/**
+ * Bottom sheet for displaying the last [TRANSACTION_HISTORY_LIMIT] transactions generated by the
+ * Sentry SDK.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun Nav2TransactionHistorySheet(
+ transactions: List,
+ showActivityUiLoadTransactionDelayMessage: Boolean,
+ onDismissRequest: () -> Unit,
+ onOpenTransaction: (String) -> Unit,
+ onDumpTransactionUrl: (String) -> Unit,
+ onCopyTransactionUrl: (String) -> Unit,
+) {
+ ModalBottomSheet(onDismissRequest = onDismissRequest) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ "Recent transactions",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ "Newest first. Open a few Nav2 routes, then return here to inspect the last 10 transactions emitted by the SDK.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (transactions.isEmpty()) {
+ EmptyTransactionHistory(showActivityUiLoadTransactionDelayMessage)
+ } else {
+ transactions.forEach { transaction ->
+ TransactionCard(
+ transaction = transaction,
+ onOpenTransaction = onOpenTransaction,
+ onDumpTransactionUrl = onDumpTransactionUrl,
+ onCopyTransactionUrl = onCopyTransactionUrl,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmptyTransactionHistory(showActivityUiLoadTransactionDelayMessage: Boolean) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ if (showActivityUiLoadTransactionDelayMessage) {
+ ACTIVITY_UI_LOAD_EMPTY_HISTORY_MESSAGE
+ } else {
+ EMPTY_HISTORY_MESSAGE
+ },
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+}
+
+@Composable
+private fun TransactionCard(
+ transaction: Nav2TransactionTrace,
+ onOpenTransaction: (String) -> Unit,
+ onDumpTransactionUrl: (String) -> Unit,
+ onCopyTransactionUrl: (String) -> Unit,
+) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ transaction.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ listOfNotNull(transaction.operation, transaction.status).joinToString(" - "),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Text(
+ "Tab: ${transaction.tab}",
+ style = MaterialTheme.typography.labelLarge,
+ fontFamily = FontFamily.Monospace,
+ )
+ }
+
+ TransactionWaterfall(transaction)
+
+ if (transaction.sentryUrl == null) {
+ Text(
+ "Sentry URL unavailable for this DSN.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(onClick = { onOpenTransaction(transaction.sentryUrl) }) {
+ Text("Open in Sentry")
+ }
+ TextButton(onClick = { onDumpTransactionUrl(transaction.sentryUrl) }) {
+ Text("Dump URL")
+ }
+ TextButton(onClick = { onCopyTransactionUrl(transaction.sentryUrl) }) {
+ Text("Copy URL")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TransactionWaterfall(transaction: Nav2TransactionTrace) {
+ val rows = transaction.spans.flattenTraceRows()
+ val scrollState = rememberScrollState()
+ Column(modifier = Modifier.horizontalScroll(scrollState)) {
+ TimelineHeader(transaction.durationMillis)
+ TraceRow(
+ label = transaction.name,
+ operation = transaction.operation,
+ level = 0,
+ startOffsetMillis = 0.0,
+ durationMillis = transaction.durationMillis,
+ totalDurationMillis = transaction.durationMillis,
+ color = ROOT_SPAN_COLOR,
+ showBranch = false,
+ )
+ rows.forEachIndexed { index, row ->
+ TraceRow(
+ label = row.span.description ?: row.span.operation,
+ operation = row.span.operation,
+ level = row.level,
+ startOffsetMillis = row.span.startOffsetMillis,
+ durationMillis = row.span.durationMillis,
+ totalDurationMillis = transaction.durationMillis,
+ color = spanBarColor(index, row.span.operation),
+ showBranch = true,
+ )
+ }
+ }
+}
+
+@Composable
+private fun TimelineHeader(totalDurationMillis: Double) {
+ Row(modifier = Modifier.width(WATERFALL_WIDTH).height(28.dp)) {
+ Spacer(Modifier.width(LABEL_WIDTH))
+ Row(
+ modifier = Modifier.width(TIMELINE_WIDTH),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ listOf(0.0, 0.25, 0.5, 0.75, 1.0).forEach { fraction ->
+ Text(
+ (totalDurationMillis * fraction).formatMillis(),
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TraceRow(
+ label: String,
+ operation: String,
+ level: Int,
+ startOffsetMillis: Double,
+ durationMillis: Double,
+ totalDurationMillis: Double,
+ color: Color,
+ showBranch: Boolean,
+) {
+ Row(
+ modifier = Modifier.width(WATERFALL_WIDTH).height(34.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ modifier = Modifier.width(LABEL_WIDTH).padding(end = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Spacer(Modifier.width((level * 16).dp))
+ if (showBranch) {
+ Text("|-", color = MaterialTheme.colorScheme.outline)
+ Spacer(Modifier.width(4.dp))
+ }
+ Text(
+ "$operation - $label",
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = if (level == 0) FontWeight.Bold else FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ BoxWithConstraints(modifier = Modifier.width(TIMELINE_WIDTH).height(24.dp)) {
+ val safeTotalDuration = totalDurationMillis.coerceAtLeast(1.0)
+ val startFraction = (startOffsetMillis / safeTotalDuration).coerceIn(0.0, 1.0).toFloat()
+ val widthFraction = (durationMillis / safeTotalDuration).coerceIn(0.0, 1.0).toFloat()
+ val barX = maxWidth * startFraction
+ val barWidth = (maxWidth * widthFraction).coerceAtLeast(3.dp).coerceAtMost(maxWidth - barX)
+
+ TimelineGrid()
+ Box(
+ modifier =
+ Modifier.offset(x = barX)
+ .width(barWidth)
+ .height(12.dp)
+ .align(Alignment.CenterStart)
+ .clip(RoundedCornerShape(2.dp))
+ .background(color)
+ )
+ Text(
+ durationMillis.formatMillis(),
+ modifier = Modifier.offset(x = barX + barWidth + 4.dp).align(Alignment.CenterStart),
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+@Composable
+private fun TimelineGrid() {
+ Row(modifier = Modifier.width(TIMELINE_WIDTH).height(24.dp)) {
+ repeat(4) {
+ Box(modifier = Modifier.weight(1f).height(24.dp)) {
+ Box(
+ modifier =
+ Modifier.width(1.dp)
+ .height(24.dp)
+ .align(Alignment.CenterEnd)
+ .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
+ )
+ }
+ }
+ }
+}
+
+private fun List.flattenTraceRows(level: Int = 1): List =
+ flatMap { span ->
+ listOf(TraceRowData(span = span, level = level)) + span.children.flattenTraceRows(level + 1)
+ }
+
+private fun spanBarColor(index: Int, operation: String): Color =
+ when {
+ operation.startsWith("ui.compose") -> COMPOSE_SPAN_COLOR
+ operation.startsWith("ui.render") -> RENDER_SPAN_COLOR
+ operation.startsWith("http") -> HTTP_SPAN_COLOR
+ else -> CHILD_SPAN_COLORS[index % CHILD_SPAN_COLORS.size]
+ }
+
+private fun Double.formatMillis(): String =
+ when {
+ this < 10.0 -> String.format(Locale.ROOT, "%.2fms", this)
+ this < 100.0 -> String.format(Locale.ROOT, "%.1fms", this)
+ else -> "${roundToInt()}ms"
+ }
+
+private data class TraceRowData(val span: Nav2TraceSpan, val level: Int)
+
+private const val EMPTY_HISTORY_MESSAGE = "No transactions have been emitted yet."
+private const val ACTIVITY_UI_LOAD_EMPTY_HISTORY_MESSAGE =
+ "No transactions have been emitted yet. ui.load transactions can take up to 30 seconds to " +
+ "appear after the last span is produced."
+private val ROOT_SPAN_COLOR = Color(0xFFE95F5C)
+private val COMPOSE_SPAN_COLOR = Color(0xFF5B3DB6)
+private val RENDER_SPAN_COLOR = Color(0xFFBBD233)
+private val HTTP_SPAN_COLOR = Color(0xFF2F80ED)
+private val CHILD_SPAN_COLORS =
+ listOf(Color(0xFF7553D6), Color(0xFF9B51E0), Color(0xFF27AE60), Color(0xFFF2C94C))
+
+private val LABEL_WIDTH = 260.dp
+private val TIMELINE_WIDTH = 420.dp
+private val WATERFALL_WIDTH = LABEL_WIDTH + TIMELINE_WIDTH
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/NavigationPerformanceControls.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/NavigationPerformanceControls.kt
new file mode 100644
index 00000000000..271530e6a3d
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/NavigationPerformanceControls.kt
@@ -0,0 +1,280 @@
+package io.sentry.samples.android.navigation
+
+import android.os.Trace
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+
+/** State holder backing the [Nav2Scenario.PERFORMANCE] tab. */
+internal class NavigationPerformanceState {
+
+ var stackDepth by mutableIntStateOf(30)
+ var autoRecompose by mutableStateOf(false)
+ var autoNavigate by mutableStateOf(false)
+
+ var generation = 0
+ private set
+
+ var recompositionRequests by mutableIntStateOf(0)
+ private set
+
+ var navigationMutations by mutableIntStateOf(0)
+ private set
+
+ var destinationChanges by mutableIntStateOf(0)
+ private set
+
+ fun resetCounters() {
+ recompositionRequests = 0
+ navigationMutations = 0
+ destinationChanges = 0
+ }
+
+ fun stopAutomaticWork() {
+ autoRecompose = false
+ autoNavigate = false
+ }
+
+ fun nextGeneration(): Int {
+ generation++
+ return generation
+ }
+
+ fun markRecompositionRequest() {
+ recompositionRequests++
+ }
+
+ fun markNavigationMutation() {
+ navigationMutations++
+ }
+
+ fun markDestinationChange() {
+ destinationChanges++
+ }
+}
+
+/** "Performance" tab content. */
+@Composable
+internal fun NavigationPerformancePanel(
+ title: String,
+ description: String,
+ currentRoute: String,
+ backStack: String,
+ state: NavigationPerformanceState,
+ onBuildStack: () -> Unit,
+ onReplaceTop: () -> Unit,
+) {
+ LaunchedEffect(state.autoRecompose) {
+ while (state.autoRecompose) {
+ delay(250)
+ if (!state.autoRecompose) {
+ break
+ }
+ traceNavigationPerformanceSection("Nav2Stress.autoRecompose") {
+ state.markRecompositionRequest()
+ }
+ }
+ }
+
+ LaunchedEffect(state.autoNavigate) {
+ while (state.autoNavigate) {
+ delay(500)
+ if (!state.autoNavigate) {
+ break
+ }
+ traceNavigationPerformanceSection("Nav2Stress.autoNavigate") { onReplaceTop() }
+ }
+ }
+
+ Column(
+ modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
+ Text(description, style = MaterialTheme.typography.bodyMedium)
+
+ PerfCard(title = "Current State") {
+ PerfInfoRow("Current route", currentRoute)
+ PerfInfoRow("Tracked stack", backStack)
+ }
+
+ PerfCard(title = "Stress Controls") {
+ PerfStepper(
+ label = "Stack depth",
+ value = state.stackDepth,
+ onDecrement = { state.stackDepth = (state.stackDepth - 1).coerceAtLeast(1) },
+ onIncrement = { state.stackDepth = (state.stackDepth + 1).coerceAtMost(100) },
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = onBuildStack, modifier = Modifier.weight(1f)) { Text("Build Stack") }
+ Button(onClick = onReplaceTop, modifier = Modifier.weight(1f)) { Text("Replace Top") }
+ }
+
+ Button(
+ onClick = { state.markRecompositionRequest() },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Force Unrelated Recomposition")
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ PerfToggleButton(
+ selected = state.autoRecompose,
+ label = if (state.autoRecompose) "Stop Recompose" else "Auto Recompose",
+ onClick = { state.autoRecompose = !state.autoRecompose },
+ modifier = Modifier.weight(1f),
+ )
+ PerfToggleButton(
+ selected = state.autoNavigate,
+ label = if (state.autoNavigate) "Stop Navigate" else "Auto Navigate",
+ onClick = { state.autoNavigate = !state.autoNavigate },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+
+ PerfCard(title = "Counters") {
+ PerfInfoRow("Recomposition requests", state.recompositionRequests.toString())
+ PerfInfoRow("Navigation mutations", state.navigationMutations.toString())
+ PerfInfoRow("Destination changes", state.destinationChanges.toString())
+ Button(onClick = { state.resetCounters() }, modifier = Modifier.fillMaxWidth()) {
+ Text("Reset Counters")
+ }
+ }
+ }
+}
+
+@Composable
+private fun PerfCard(title: String, content: @Composable ColumnScope.() -> Unit) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ content()
+ }
+ }
+}
+
+@Composable
+private fun PerfInfoRow(label: String, value: String) {
+ Row(
+ modifier =
+ Modifier.fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(8.dp))
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(label, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
+ Spacer(Modifier.size(12.dp))
+ Text(value, modifier = Modifier.weight(1f))
+ }
+}
+
+@Composable
+private fun PerfStepper(
+ label: String,
+ value: Int,
+ onDecrement: () -> Unit,
+ onIncrement: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(label, fontWeight = FontWeight.Bold)
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedButton(
+ modifier = Modifier.size(44.dp),
+ contentPadding = PaddingValues(0.dp),
+ onClick = onDecrement,
+ ) {
+ Text("-", style = MaterialTheme.typography.titleLarge)
+ }
+ Text(
+ value.toString(),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ OutlinedButton(
+ modifier = Modifier.size(44.dp),
+ contentPadding = PaddingValues(0.dp),
+ onClick = onIncrement,
+ ) {
+ Text("+", style = MaterialTheme.typography.titleLarge)
+ }
+ }
+ }
+}
+
+@Composable
+private fun PerfToggleButton(
+ selected: Boolean,
+ label: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (selected) {
+ Button(onClick = onClick, modifier = modifier) { Text(label) }
+ } else {
+ OutlinedButton(onClick = onClick, modifier = modifier) { Text(label) }
+ }
+}
+
+internal fun traceNavigationPerformanceSection(sectionName: String, block: () -> Unit) {
+ Trace.beginSection(sectionName)
+ try {
+ block()
+ } finally {
+ Trace.endSection()
+ }
+}
+
+internal fun navigationPerformanceBackStackPreview(entries: List): String {
+ if (entries.size <= 8) {
+ return entries.joinToString(" -> ")
+ }
+
+ return "${entries.size} entries: " +
+ entries.take(3).joinToString(" -> ") +
+ " -> ... -> " +
+ entries.takeLast(3).joinToString(" -> ")
+}
diff --git a/sentry-samples/sentry-samples-android/src/main/res/navigation/nav2_sample.xml b/sentry-samples/sentry-samples-android/src/main/res/navigation/nav2_sample.xml
new file mode 100644
index 00000000000..142f7e97a46
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/res/navigation/nav2_sample.xml
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/colors.xml b/sentry-samples/sentry-samples-android/src/main/res/values/colors.xml
index 9f2da8a4dec..b29ba8c4013 100644
--- a/sentry-samples/sentry-samples-android/src/main/res/values/colors.xml
+++ b/sentry-samples/sentry-samples-android/src/main/res/values/colors.xml
@@ -3,4 +3,5 @@
#7B52FB
#6B42EB
#FF4BB8
+ #E27AB8
diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml b/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml
new file mode 100644
index 00000000000..6cf65a750c8
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+