diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts
index cf4b054e354..a9d4ea8ac5f 100644
--- a/sentry-samples/sentry-samples-android/build.gradle.kts
+++ b/sentry-samples/sentry-samples-android/build.gradle.kts
@@ -208,6 +208,7 @@ dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.recyclerview)
+ implementation(libs.androidx.viewpager2)
implementation(libs.androidx.browser)
implementation(libs.androidx.room3.runtime)
implementation(libs.bundles.androidx.room2)
@@ -230,6 +231,9 @@ dependencies {
debugImplementation(projects.sentryAndroidDistribution)
debugImplementation(libs.leakcanary)
+
+ testImplementation(libs.google.truth)
+ testImplementation(libs.kotlin.test.junit)
}
abstract class ToggleNativeLoggingTask : Exec() {
diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
index 4e80ddfd865..da8b00d97a0 100644
--- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
+++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml
@@ -123,6 +123,30 @@
android:screenOrientation="portrait"
android:theme="@style/AppTheme.Main" />
+
+
+
+
+
+
+
+
+
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt
index 5b935a01e01..3fad5ffe277 100644
--- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt
@@ -39,6 +39,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import io.sentry.compose.navigation3.SentryBackStackEntry
@@ -75,9 +77,6 @@ class Nav3Activity : ComponentActivity() {
private var routeWorkOptions by mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN))
- private var isTransactionHistoryActive = false
- private val transactionHistory =
- NavigationTransactionHistory(isActive = { isTransactionHistoryActive })
private var showTransactionHistorySheet by mutableStateOf(false)
private var showActivityUiLoadTransactionDelayMessage = false
@@ -99,14 +98,14 @@ class Nav3Activity : ComponentActivity() {
cancelCurrentActivityUiLoadTransaction()
}
- transactionHistory.install()
+ NavigationTransactionHistory.install()
setContent {
Nav3SampleTheme {
Nav3SampleApp(
performanceState = performanceState,
configuration = configuration,
- transactions = transactionHistory.transactions,
+ transactions = NavigationTransactionHistory.transactions,
showActivityUiLoadTransactionDelayMessage = showActivityUiLoadTransactionDelayMessage,
routeWorkOptions = routeWorkOptions,
showTransactionHistorySheet = showTransactionHistorySheet,
@@ -124,13 +123,7 @@ class Nav3Activity : ComponentActivity() {
}
}
- override fun onStart() {
- super.onStart()
- isTransactionHistoryActive = true
- }
-
override fun onStop() {
- isTransactionHistoryActive = false
performanceState.stopAutomaticWork()
super.onStop()
}
@@ -139,7 +132,6 @@ class Nav3Activity : ComponentActivity() {
if (isFinishing) {
previousConfig.applyToCurrentOptions()
}
- transactionHistory.uninstall()
super.onDestroy()
}
@@ -361,7 +353,9 @@ private fun Nav3SampleApp(
}
Scaffold(
- modifier = Modifier.fillMaxSize().safeDrawingPadding(),
+ // Expose test tags as resource IDs so adb/uiautomator can target them.
+ modifier =
+ Modifier.fillMaxSize().safeDrawingPadding().semantics { testTagsAsResourceId = true },
containerColor = MaterialTheme.colorScheme.background,
topBar = {
Nav3TopBar(
@@ -464,6 +458,28 @@ private fun Nav3SampleApp(
)
}
}
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ MultiActivityRoute {
+ activity?.startActivity(
+ Intent(activity, Nav3ProductsActivity::class.java)
+ .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) })
+ )
+ }
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, selectedScenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ MultiFragmentRoute {
+ activity?.startActivity(
+ Intent(activity, Nav3FragmentsActivity::class.java)
+ .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) })
+ )
+ }
+ }
+ }
entry { route ->
TracedNav3Route(route, selectedScenario) {
Nav3RouteWorkEffect(route, routeWorkOptions)
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt
new file mode 100644
index 00000000000..8f825f77f22
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt
@@ -0,0 +1,491 @@
+package io.sentry.samples.android.navigation.nav3
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountTree
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.currentStateAsState
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.ui.NavDisplay
+import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
+import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner
+import io.sentry.compose.SentryModifier.sentryTag
+import io.sentry.compose.navigation3.SentryBackStackEntry
+import io.sentry.compose.navigation3.SentryNavEffect
+import io.sentry.compose.navigation3.SentryNavOptions
+import io.sentry.samples.android.navigation.common.NavigationSampleConfig
+import io.sentry.samples.android.navigation.common.RouteWorkOption
+import io.sentry.samples.android.navigation.common.hasOnlyActivityUiLoadTransactions
+import io.sentry.samples.android.navigation.common.showRouteWorkDialog
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/*
+ * Activities that each own a Nav3 root (back stack, `SentryNavEffect`, and `NavDisplay`), which let us
+ * exercise how separate effects hand off during Activity transactions.
+ */
+
+/** Keeps its navigation effect alive while checkout runs in another Activity. */
+class Nav3ProductsActivity : ComponentActivity() {
+
+ private var backStack: SnapshotStateList? = null
+
+ /** An order ID returned before [backStack] exists (e.g., after Activity recreation). */
+ private var pendingOrderId: String? = null
+
+ // Pushes a destination from the result callback itself, which runs before this Activity resumes,
+ // like an app that navigates in response to an Activity result.
+ private val checkoutForResult =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val orderId = result.data?.getStringExtra(EXTRA_ORDER_ID) ?: return@registerForActivityResult
+ backStack?.add(Nav3Route.Confirmation(orderId)) ?: run { pendingOrderId = orderId }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration()
+ NavigationTransactionHistory.install()
+ if (!configuration.enableActivityUiLoadTransaction) {
+ cancelCurrentActivityUiLoadTransaction()
+ }
+ setContent {
+ val stack = rememberSaveableNav3BackStack(Nav3Route.ProductList)
+ SideEffect {
+ backStack = stack
+ pendingOrderId?.let { orderId ->
+ pendingOrderId = null
+ stack.add(Nav3Route.Confirmation(orderId))
+ }
+ }
+ Nav3SampleTheme {
+ MultiRootNavDisplay(
+ activity = this,
+ title = "Products Activity",
+ initialRoute = Nav3Route.ProductList,
+ scenario = Nav3Scenario.MULTI_ACTIVITY,
+ configuration = configuration,
+ onFinish = { finish() },
+ backStack = stack,
+ onCheckout = { productId -> startActivity(checkoutIntent(productId, configuration)) },
+ productDetailActions =
+ listOf(
+ ProductDetailAction(
+ label = "Go to Translucent Checkout",
+ testTag = nav3TestTag("product_detail_go_to_translucent_checkout"),
+ onClick = { productId ->
+ startActivity(
+ checkoutIntent(productId, configuration)
+ .setClass(this, Nav3TranslucentCheckoutActivity::class.java)
+ )
+ },
+ ),
+ ProductDetailAction(
+ label = "Go to Checkout for Result",
+ testTag = nav3TestTag("product_detail_go_to_checkout_for_result"),
+ onClick = { productId ->
+ checkoutForResult.launch(
+ checkoutIntent(productId, configuration).putExtra(EXTRA_RETURN_RESULT, true)
+ )
+ },
+ ),
+ ProductDetailAction(
+ label = "Go to Checkout (Crash on First Composition)",
+ testTag = nav3TestTag("product_detail_go_to_checkout_crash"),
+ onClick = { productId ->
+ startActivity(
+ checkoutIntent(productId, configuration)
+ .putExtra(EXTRA_CRASH_ON_FIRST_COMPOSITION, true)
+ )
+ },
+ ),
+ ),
+ )
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ backStack = null
+ super.onDestroy()
+ }
+
+ private fun checkoutIntent(productId: String, configuration: NavigationSampleConfig): Intent =
+ Intent(this, Nav3CheckoutActivity::class.java)
+ .putExtra(EXTRA_PRODUCT_ID, productId)
+ .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) })
+}
+
+/** Owns the checkout back stack independently of the Products Activity. */
+open class Nav3CheckoutActivity : ComponentActivity() {
+
+ /** Whether to show checkout as a sheet, leaving the Activity underneath partly visible. */
+ protected open val showAsSheet: Boolean = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration()
+ val productId = intent.getStringExtra(EXTRA_PRODUCT_ID) ?: "42"
+ val returnsResult = intent.getBooleanExtra(EXTRA_RETURN_RESULT, false)
+ // Only crash on the first launch, not when the system restores this Activity.
+ val crashOnFirstComposition =
+ savedInstanceState == null && intent.getBooleanExtra(EXTRA_CRASH_ON_FIRST_COMPOSITION, false)
+ NavigationTransactionHistory.install()
+ if (!configuration.enableActivityUiLoadTransaction) {
+ cancelCurrentActivityUiLoadTransaction()
+ }
+ setContent {
+ Nav3SampleTheme {
+ if (showAsSheet) {
+ CheckoutSheet {
+ CheckoutRoot(productId, configuration, returnsResult, crashOnFirstComposition)
+ }
+ } else {
+ CheckoutRoot(productId, configuration, returnsResult, crashOnFirstComposition)
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun CheckoutRoot(
+ productId: String,
+ configuration: NavigationSampleConfig,
+ returnsResult: Boolean,
+ crashOnFirstComposition: Boolean,
+ ) {
+ MultiRootNavDisplay(
+ activity = this,
+ title = if (showAsSheet) "Translucent Checkout Activity" else "Checkout Activity",
+ initialRoute = Nav3Route.Checkout(productId),
+ scenario = Nav3Scenario.MULTI_ACTIVITY,
+ configuration = configuration,
+ onFinish = { finish() },
+ onCompleteOrder =
+ if (returnsResult) {
+ { orderId ->
+ setResult(RESULT_OK, Intent().putExtra(EXTRA_ORDER_ID, orderId))
+ finish()
+ }
+ } else {
+ null
+ },
+ crashOnFirstComposition = crashOnFirstComposition,
+ )
+ }
+}
+
+/**
+ * Checkout in a translucent Activity. The Products Activity underneath stays visible, so it's
+ * paused rather than stopped while checkout is open.
+ */
+class Nav3TranslucentCheckoutActivity : Nav3CheckoutActivity() {
+ override val showAsSheet: Boolean = true
+}
+
+/** Shows [content] as a bottom sheet over a scrim, leaving the top of the screen see-through. */
+@Composable
+private fun CheckoutSheet(content: @Composable () -> Unit) {
+ Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f))) {
+ Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.7f).align(Alignment.BottomCenter)) {
+ content()
+ }
+ }
+}
+
+/** An extra Product Detail button, given the product's ID when clicked. */
+internal class ProductDetailAction(
+ val label: String,
+ val testTag: String,
+ val onClick: (productId: String) -> Unit,
+)
+
+@Composable
+internal fun MultiActivityRoute(onOpenProducts: () -> Unit) {
+ RouteScaffold(
+ routeSpec = Nav3Route.MultiActivity.routeSpec(),
+ testTagPrefix = nav3TestTag("route_multi_activity"),
+ cardContent = {
+ RouteButton(
+ "Open products Activity",
+ onClick = onOpenProducts,
+ testTag = nav3TestTag("multi_activity_open_products"),
+ )
+ },
+ )
+}
+
+/**
+ * A self-contained Nav3 root (back stack, [SentryNavEffect], and [NavDisplay]) shared by the
+ * Multi-Activity and Multi-Fragment samples, so each Activity or Fragment owns its own effect.
+ *
+ * @param backStack This root's back stack. Pass one to navigate it from outside the composition.
+ * @param onCheckout Opens checkout outside this root (e.g., another Activity or Fragment). When
+ * `null`, checkout is pushed onto this root's own back stack.
+ * @param productDetailActions Additional Product Detail buttons. Product Detail always offers a
+ * button that pushes a confirmation after a delay, so the push can happen while this root is
+ * covered by another one.
+ * @param onCompleteOrder Handles Checkout's "Complete Order" outside this root (e.g., by returning
+ * a result). When `null`, the confirmation is pushed onto this root's own back stack.
+ * @param crashOnFirstComposition Crashes while composing the Checkout destination, i.e., while the
+ * root's first screen is being created, to check that the crash is attributed to that screen.
+ */
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+internal fun MultiRootNavDisplay(
+ activity: ComponentActivity,
+ title: String,
+ initialRoute: Nav3Route,
+ scenario: Nav3Scenario,
+ configuration: NavigationSampleConfig,
+ onFinish: () -> Unit,
+ backStack: SnapshotStateList = rememberSaveableNav3BackStack(initialRoute),
+ onCheckout: ((String) -> Unit)? = null,
+ productDetailActions: List = emptyList(),
+ onCompleteOrder: ((orderId: String) -> Unit)? = null,
+ crashOnFirstComposition: Boolean = false,
+) {
+ val coroutineScope = rememberCoroutineScope()
+ var routeWorkOptions by remember { mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN)) }
+ var showTransactionHistorySheet by remember { mutableStateOf(false) }
+ var showCrashConfirmation by remember { mutableStateOf(false) }
+ val options =
+ remember(configuration) {
+ SentryNavOptions {
+ enableNavigationBreadcrumbs = configuration.enableNavigationBreadcrumbs
+ enableNavigationTransactions = configuration.enableNavigationTransactions
+ captureBackStack = configuration.captureBackStack
+ maxCapturedBackStackEntries = configuration.maxCapturedBackStackEntries
+ }
+ }
+ SentryNavEffect(
+ backStack = backStack,
+ backStackEntryMapper = { route ->
+ SentryBackStackEntry(name = route.routeName, arguments = route.arguments)
+ },
+ options = options,
+ )
+ val onBack: () -> Unit = {
+ if (backStack.size > 1) {
+ backStack.removeLastOrNull()
+ } else {
+ onFinish()
+ }
+ }
+ Scaffold(
+ // Expose test tags as resource IDs so adb/uiautomator can target them.
+ modifier =
+ Modifier.fillMaxSize().safeDrawingPadding().semantics { testTagsAsResourceId = true },
+ topBar = {
+ Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)) {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ title,
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.weight(1f),
+ )
+ IconButton(
+ onClick = { showTransactionHistorySheet = true },
+ modifier =
+ Modifier.sentryTag(nav3InteractionTag("Recent Transactions"))
+ .testTag(nav3TestTag("top_bar_recent_transactions")),
+ ) {
+ Icon(Icons.Filled.AccountTree, contentDescription = "Recent transactions")
+ }
+ IconButton(
+ onClick = {
+ showRouteWorkDialog(activity, routeWorkOptions) { routeWorkOptions = it }
+ },
+ modifier =
+ Modifier.sentryTag(nav3InteractionTag("Route Work Settings"))
+ .testTag(nav3TestTag("top_bar_route_work_settings")),
+ ) {
+ Icon(Icons.Filled.Settings, contentDescription = "Route work settings")
+ }
+ }
+ Text("Current route: ${backStack.last().displayRoute()}")
+ }
+ },
+ bottomBar = {
+ SentryControls(
+ onCaptureException = { captureSampleException("Nav3 ${scenario.label}") },
+ onCrashApp = { showCrashConfirmation = true },
+ )
+ },
+ ) { padding ->
+ // Only handle back while this root is resumed. Otherwise, a root that's still composed but no
+ // longer current (e.g., a Fragment animating out, or a preloaded ViewPager2 page) could consume
+ // back presses meant for the current root, popping a back stack the user can't see.
+ val isResumed =
+ LocalLifecycleOwner.current.lifecycle
+ .currentStateAsState()
+ .value
+ .isAtLeast(Lifecycle.State.RESUMED)
+ // Pass the parent explicitly: it's null while this root's view is detached (e.g., a recycled
+ // ViewPager2 page that still recomposes), where the default parameter would throw.
+ val parentDispatcherOwner = LocalNavigationEventDispatcherOwner.current
+ CompositionLocalProvider(
+ LocalNavigationEventDispatcherOwner provides
+ rememberNavigationEventDispatcherOwner(enabled = isResumed, parent = parentDispatcherOwner)
+ ) {
+ NavDisplay(
+ backStack = backStack,
+ modifier = Modifier.fillMaxSize().padding(padding),
+ onBack = onBack,
+ entryProvider =
+ entryProvider {
+ entry { route ->
+ TracedNav3Route(route, scenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ ProductListRoute(backStack)
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, scenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ ProductDetailRoute(
+ route = route,
+ backStack = backStack,
+ onCheckout = {
+ onCheckout?.invoke(route.productId)
+ ?: backStack.add(Nav3Route.Checkout(route.productId))
+ },
+ showOverlays = false,
+ extraActions = {
+ RouteButton(
+ "Push Confirmation in $DELAYED_NAVIGATION_SECONDS s",
+ onClick = {
+ coroutineScope.launch {
+ delay(DELAYED_NAVIGATION_SECONDS * 1_000L)
+ backStack += Nav3Route.Confirmation("delayed-${route.productId}")
+ }
+ },
+ testTag = nav3TestTag("product_detail_delayed_confirmation"),
+ )
+ productDetailActions.forEach { action ->
+ RouteButton(
+ action.label,
+ onClick = { action.onClick(route.productId) },
+ testTag = action.testTag,
+ )
+ }
+ },
+ )
+ }
+ }
+ entry { route ->
+ if (crashOnFirstComposition) {
+ throw RuntimeException(
+ "Fatal Nav3 ${scenario.label} crash during first composition"
+ )
+ }
+ TracedNav3Route(route, scenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ CheckoutRoute(
+ route = route,
+ backStack = backStack,
+ onCompleteOrder = onCompleteOrder ?: { backStack += Nav3Route.Confirmation(it) },
+ )
+ }
+ }
+ entry { route ->
+ TracedNav3Route(route, scenario) {
+ Nav3RouteWorkEffect(route, routeWorkOptions)
+ ConfirmationRoute(route, backStack, rootRoute = initialRoute)
+ }
+ }
+ },
+ )
+ }
+ }
+ if (showCrashConfirmation) {
+ AlertDialog(
+ onDismissRequest = { showCrashConfirmation = false },
+ title = { Text("Crash app?") },
+ text = { Text("This will throw an uncaught exception and close the sample app.") },
+ dismissButton = {
+ TextButton(onClick = { showCrashConfirmation = false }) { Text("Cancel") }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showCrashConfirmation = false
+ crashSampleApp("Nav3 ${scenario.label}")
+ }
+ ) {
+ Text("Crash")
+ }
+ },
+ )
+ }
+ if (showTransactionHistorySheet) {
+ NavigationTransactionHistorySheet(
+ sampleName = "Nav3",
+ transactions = NavigationTransactionHistory.transactions,
+ showActivityUiLoadTransactionDelayMessage = configuration.hasOnlyActivityUiLoadTransactions,
+ onDismissRequest = { showTransactionHistorySheet = false },
+ onOpenTransaction = { url ->
+ activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ },
+ onDumpTransactionUrl = { url ->
+ Log.i("Nav3MultiActivity", "Sentry transaction URL: $url")
+ Toast.makeText(activity, "Dumped transaction URL to logcat.", Toast.LENGTH_SHORT).show()
+ },
+ onCopyTransactionUrl = { url ->
+ val clipboard = activity.getSystemService(ClipboardManager::class.java)
+ clipboard.setPrimaryClip(ClipData.newPlainText("Sentry transaction URL", url))
+ Toast.makeText(activity, "Copied transaction URL to clipboard.", Toast.LENGTH_SHORT).show()
+ },
+ )
+ }
+}
+
+private const val DELAYED_NAVIGATION_SECONDS = 5
+private const val EXTRA_PRODUCT_ID = "io.sentry.samples.android.navigation.nav3.product_id"
+private const val EXTRA_RETURN_RESULT = "io.sentry.samples.android.navigation.nav3.return_result"
+private const val EXTRA_ORDER_ID = "io.sentry.samples.android.navigation.nav3.order_id"
+private const val EXTRA_CRASH_ON_FIRST_COMPOSITION =
+ "io.sentry.samples.android.navigation.nav3.crash_on_first_composition"
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt
new file mode 100644
index 00000000000..1ca11284eeb
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt
@@ -0,0 +1,420 @@
+package io.sentry.samples.android.navigation.nav3
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+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.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
+import androidx.compose.ui.unit.dp
+import androidx.core.graphics.Insets
+import androidx.core.os.bundleOf
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.fragment.app.FragmentContainerView
+import androidx.fragment.app.commit
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import androidx.viewpager2.widget.ViewPager2
+import io.sentry.samples.android.R
+import io.sentry.samples.android.navigation.common.NavigationSampleConfig
+
+/**
+ * Hosts Fragments that each own a Nav3 root (back stack, `SentryNavEffect`, and `NavDisplay`), to
+ * exercise how separate effects hand off during Fragment transactions:
+ *
+ * - `replace` + back stack (Products → Checkout), optionally with a slow enter animation;
+ * - a full-screen `DialogFragment` overlay whose host Fragment stays resumed;
+ * - a `ViewPager2` of Fragments, where neighboring pages are preloaded at STARTED.
+ */
+// FragmentActivity rather than AppCompatActivity: AppCompatActivity.setContentView doesn't install
+// the NavigationEventDispatcherOwner that NavDisplay needs on the view tree.
+class Nav3FragmentsActivity : FragmentActivity() {
+
+ /** When true, Fragment `replace` uses a slow enter animation. */
+ internal var slowTransitions by mutableStateOf(false)
+
+ internal val configuration: NavigationSampleConfig
+ get() = (intent.extras ?: Bundle()).nav3SampleConfiguration()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ NavigationTransactionHistory.install()
+ if (!configuration.enableActivityUiLoadTransaction) {
+ cancelCurrentActivityUiLoadTransaction()
+ }
+
+ val controls =
+ ComposeView(this).apply {
+ setContent {
+ Nav3SampleTheme {
+ MultiFragmentControls(
+ slowTransitions = slowTransitions,
+ onSlowTransitionsChanged = { slowTransitions = it },
+ onOpenCheckoutSheet = ::openCheckoutSheet,
+ onOpenPager = ::openPager,
+ )
+ }
+ }
+ }
+ val container = FragmentContainerView(this).apply { id = R.id.nav3_multi_fragment_container }
+ // The controls above already pad for the status bar; keep Fragments from padding for it again.
+ ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets ->
+ WindowInsetsCompat.Builder(insets)
+ .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.NONE)
+ .setInsets(WindowInsetsCompat.Type.displayCutout(), Insets.NONE)
+ .build()
+ }
+ setContentView(
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ addView(controls, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
+ addView(container, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
+ }
+ )
+
+ if (savedInstanceState == null) {
+ supportFragmentManager.commit {
+ setReorderingAllowed(true)
+ add(R.id.nav3_multi_fragment_container, Nav3RootFragment.products())
+ }
+ }
+ }
+
+ internal fun openCheckoutFragment(productId: String) {
+ supportFragmentManager.commit {
+ setReorderingAllowed(true)
+ if (slowTransitions) {
+ setCustomAnimations(
+ R.anim.nav3_slow_fade_in,
+ R.anim.nav3_slow_hold,
+ R.anim.nav3_slow_fade_in,
+ R.anim.nav3_slow_hold,
+ )
+ } else {
+ setCustomAnimations(
+ android.R.anim.fade_in,
+ android.R.anim.fade_out,
+ android.R.anim.fade_in,
+ android.R.anim.fade_out,
+ )
+ }
+ replace(R.id.nav3_multi_fragment_container, Nav3RootFragment.checkout(productId))
+ addToBackStack("checkout")
+ }
+ }
+
+ private fun openPager() {
+ supportFragmentManager.commit {
+ setReorderingAllowed(true)
+ replace(R.id.nav3_multi_fragment_container, Nav3PagerFragment())
+ addToBackStack("pager")
+ }
+ }
+
+ private fun openCheckoutSheet() {
+ Nav3CheckoutSheetFragment().show(supportFragmentManager, "checkout_sheet")
+ }
+
+ /** Pops this Activity's Fragment back stack, or finishes the Activity when it's empty. */
+ internal fun finishRoot() {
+ if (supportFragmentManager.backStackEntryCount > 0) {
+ supportFragmentManager.popBackStack()
+ } else {
+ finish()
+ }
+ }
+}
+
+/** A Fragment that owns one Nav3 root. Pager pages are child Fragments of [Nav3PagerFragment]. */
+class Nav3RootFragment : Fragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ val args = requireArguments()
+ val host = requireNotNull(args.getString(ARG_HOST))
+ val activity = requireActivity() as Nav3FragmentsActivity
+ val initialRoute =
+ when (args.getString(ARG_KIND)) {
+ KIND_CHECKOUT -> Nav3Route.Checkout(requireNotNull(args.getString(ARG_PRODUCT_ID)))
+ KIND_PAGE -> pageRoots[args.getInt(ARG_PAGE)]
+ else -> Nav3Route.ProductList
+ }
+ val opensCheckoutFragment = args.getString(ARG_KIND) == KIND_PRODUCTS
+
+ return nav3RootComposeView(
+ activity = activity,
+ host = host,
+ initialRoute = initialRoute,
+ configuration = activity.configuration,
+ onFinish = activity::finishRoot,
+ onCheckout = if (opensCheckoutFragment) activity::openCheckoutFragment else null,
+ )
+ }
+
+ internal companion object {
+ private const val ARG_KIND = "kind"
+ private const val ARG_HOST = "host"
+ private const val ARG_PRODUCT_ID = "product_id"
+ private const val ARG_PAGE = "page"
+ private const val KIND_PRODUCTS = "products"
+ private const val KIND_CHECKOUT = "checkout"
+ private const val KIND_PAGE = "page"
+
+ internal val pageRoots: List =
+ listOf(Nav3Route.ProductList, Nav3Route.Checkout("7"), Nav3Route.Confirmation("page-2"))
+
+ fun products() =
+ Nav3RootFragment().apply {
+ arguments = bundleOf(ARG_KIND to KIND_PRODUCTS, ARG_HOST to "ProductsFragment")
+ }
+
+ fun checkout(productId: String) =
+ Nav3RootFragment().apply {
+ arguments =
+ bundleOf(
+ ARG_KIND to KIND_CHECKOUT,
+ ARG_HOST to "CheckoutFragment",
+ ARG_PRODUCT_ID to productId,
+ )
+ }
+
+ fun page(index: Int) =
+ Nav3RootFragment().apply {
+ arguments = bundleOf(ARG_KIND to KIND_PAGE, ARG_HOST to "Page$index", ARG_PAGE to index)
+ }
+ }
+}
+
+/** A full-screen dialog with its own Nav3 root. The Fragment underneath stays resumed. */
+class Nav3CheckoutSheetFragment : DialogFragment() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NO_TITLE, R.style.AppTheme_Main)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ val activity = requireActivity() as Nav3FragmentsActivity
+ return nav3RootComposeView(
+ activity = activity,
+ host = "CheckoutSheet",
+ initialRoute = Nav3Route.Checkout("42"),
+ configuration = activity.configuration,
+ onFinish = ::dismiss,
+ onCheckout = null,
+ )
+ }
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setLayout(MATCH_PARENT, MATCH_PARENT)
+ }
+}
+
+/**
+ * A `ViewPager2` whose pages each own a Nav3 root. With `offscreenPageLimit = 1`, the neighbors of
+ * the current page are created and capped at STARTED, while only the current page is RESUMED.
+ */
+class Nav3PagerFragment : Fragment() {
+
+ private var currentPage by mutableIntStateOf(0)
+ private var pager: ViewPager2? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ val viewPager =
+ ViewPager2(requireContext()).apply {
+ id = R.id.nav3_multi_fragment_pager
+ offscreenPageLimit = 1
+ }
+ pager = viewPager
+ val header =
+ ComposeView(requireContext()).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ Nav3SampleTheme {
+ PagerHeader(currentPage = currentPage, onSelectPage = { viewPager.currentItem = it })
+ }
+ }
+ }
+ return LinearLayout(requireContext()).apply {
+ orientation = LinearLayout.VERTICAL
+ addView(header, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
+ addView(viewPager, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ val viewPager = requireNotNull(pager)
+ viewPager.adapter =
+ object : FragmentStateAdapter(childFragmentManager, viewLifecycleOwner.lifecycle) {
+ override fun getItemCount(): Int = Nav3RootFragment.pageRoots.size
+
+ override fun createFragment(position: Int): Fragment = Nav3RootFragment.page(position)
+ }
+ viewPager.registerOnPageChangeCallback(
+ object : ViewPager2.OnPageChangeCallback() {
+ override fun onPageSelected(position: Int) {
+ currentPage = position
+ }
+ }
+ )
+ }
+
+ override fun onDestroyView() {
+ pager = null
+ super.onDestroyView()
+ }
+}
+
+@Composable
+internal fun MultiFragmentRoute(onOpenFragments: () -> Unit) {
+ RouteScaffold(
+ routeSpec = Nav3Route.MultiFragment.routeSpec(),
+ testTagPrefix = nav3TestTag("route_multi_fragment"),
+ cardContent = {
+ RouteButton(
+ "Open fragments Activity",
+ onClick = onOpenFragments,
+ testTag = nav3TestTag("multi_fragment_open_fragments"),
+ )
+ },
+ )
+}
+
+private fun Fragment.nav3RootComposeView(
+ activity: ComponentActivity,
+ host: String,
+ initialRoute: Nav3Route,
+ configuration: NavigationSampleConfig,
+ onFinish: () -> Unit,
+ onCheckout: ((String) -> Unit)?,
+): ComposeView =
+ ComposeView(requireContext()).apply {
+ // Dispose with the Fragment's view, so the root's SentryNavEffect leaves the composition when
+ // the Fragment's view is destroyed (e.g., when it's replaced and put on the back stack).
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ Nav3SampleTheme {
+ CompositionLocalProvider(LocalNav3SampleHost provides host) {
+ MultiRootNavDisplay(
+ activity = activity,
+ title = host,
+ initialRoute = initialRoute,
+ scenario = Nav3Scenario.MULTI_FRAGMENT,
+ configuration = configuration,
+ onFinish = onFinish,
+ onCheckout = onCheckout,
+ )
+ }
+ }
+ }
+ }
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+private fun MultiFragmentControls(
+ slowTransitions: Boolean,
+ onSlowTransitionsChanged: (Boolean) -> Unit,
+ onOpenCheckoutSheet: () -> Unit,
+ onOpenPager: () -> Unit,
+) {
+ Surface(
+ shadowElevation = 4.dp,
+ modifier = Modifier.statusBarsPadding().semantics { testTagsAsResourceId = true },
+ ) {
+ Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Text("Fragments Activity", style = MaterialTheme.typography.titleMedium)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(
+ onClick = onOpenCheckoutSheet,
+ modifier = Modifier.testTag(nav3TestTag("multi_fragment_open_checkout_sheet")),
+ ) {
+ Text("Checkout sheet")
+ }
+ OutlinedButton(
+ onClick = onOpenPager,
+ modifier = Modifier.testTag(nav3TestTag("multi_fragment_open_pager")),
+ ) {
+ Text("Pager")
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Slow Fragment transitions", modifier = Modifier.weight(1f))
+ Switch(
+ checked = slowTransitions,
+ onCheckedChange = onSlowTransitionsChanged,
+ modifier = Modifier.testTag(nav3TestTag("multi_fragment_slow_transitions")),
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+private fun PagerHeader(currentPage: Int, onSelectPage: (Int) -> Unit) {
+ Column(
+ modifier =
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).semantics {
+ testTagsAsResourceId = true
+ }
+ ) {
+ Text(
+ "ViewPager2 page $currentPage (neighbors preloaded at STARTED)",
+ modifier = Modifier.testTag(nav3TestTag("multi_fragment_current_page")),
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Nav3RootFragment.pageRoots.indices.forEach { index ->
+ OutlinedButton(
+ onClick = { onSelectPage(index) },
+ modifier = Modifier.testTag(nav3TestTag("multi_fragment_page_$index")),
+ ) {
+ Text("Page $index")
+ }
+ }
+ }
+ }
+}
+
+private const val MATCH_PARENT = ViewGroup.LayoutParams.MATCH_PARENT
+private const val WRAP_CONTENT = ViewGroup.LayoutParams.WRAP_CONTENT
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt
index 1d4b933fdc3..2879693b0de 100644
--- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt
@@ -28,15 +28,18 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.text.font.FontWeight
@@ -47,6 +50,7 @@ import io.sentry.Sentry
import io.sentry.compose.SentryModifier.sentryTag
import io.sentry.compose.SentryTraced
import io.sentry.samples.android.R
+import io.sentry.samples.android.getActivity
import io.sentry.samples.android.navigation.common.RouteNames
import io.sentry.samples.android.navigation.common.RouteSpec
import io.sentry.samples.android.navigation.common.RouteSpecs
@@ -58,6 +62,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.HttpException
+/**
+ * Names the Activity or Fragment hosting the current Nav3 root, for the sample's transaction tags.
+ * Defaults to the Activity's class name when unset.
+ */
+internal val LocalNav3SampleHost = staticCompositionLocalOf { null }
+
@ExperimentalComposeUiApi
@Composable
internal fun TracedNav3Route(
@@ -65,7 +75,10 @@ internal fun TracedNav3Route(
scenario: Nav3Scenario,
content: @Composable BoxScope.() -> Unit,
) {
- tagCurrentNav3Scenario(scenario)
+ val activityName =
+ LocalNav3SampleHost.current ?: LocalContext.current.getActivity().javaClass.simpleName
+ // Tag after composition is applied so abandoned compositions don't tag anything.
+ SideEffect { tagCurrentNav3Scenario(scenario, activityName) }
SentryTraced(
tag = "Nav3 /${route.routeName}",
enableUserInteractionTracing = false,
@@ -73,8 +86,19 @@ internal fun TracedNav3Route(
)
}
-private fun tagCurrentNav3Scenario(scenario: Nav3Scenario) {
- Sentry.getSpan()?.setTag("sample_nav3_scenario", scenario.label)
+private fun tagCurrentNav3Scenario(scenario: Nav3Scenario, activityName: String) {
+ val sessionId = NavigationTransactionHistory.sessionId
+ Sentry.getSpan()?.let { span ->
+ span.setTag(NAV3_SCENARIO_TAG, scenario.label)
+ span.setTag(NAVIGATION_SAMPLE_ACTIVITY_TAG, activityName)
+ span.setTag(NAVIGATION_SAMPLE_SESSION_TAG, sessionId)
+ }
+ Sentry.configureScope { scope ->
+ scope.withTransaction { transaction ->
+ transaction?.setTag(NAVIGATION_SAMPLE_ACTIVITY_TAG, activityName)
+ transaction?.setTag(NAVIGATION_SAMPLE_SESSION_TAG, sessionId)
+ }
+ }
tagCurrentNavigationSampleScenario(scenario.label)
}
@@ -496,6 +520,9 @@ internal fun ProductListRoute(backStack: SnapshotStateList) {
internal fun ProductDetailRoute(
route: Nav3Route.ProductDetail,
backStack: SnapshotStateList,
+ onCheckout: () -> Unit = { backStack.add(Nav3Route.Checkout(route.productId)) },
+ showOverlays: Boolean = true,
+ extraActions: @Composable ColumnScope.() -> Unit = {},
) {
LaunchedEffect(route.productId, route.source, route.campaign) {
recordSimulatedBackgroundSpan(RouteNames.PRODUCT_DETAIL)
@@ -508,21 +535,24 @@ internal fun ProductDetailRoute(
RouteSpecs.productDetail.displayArguments(route.arguments).forEach { (label, value) ->
RouteInfo(label, value)
}
- RouteButton(
- "Show Promo Dialog",
- onClick = { backStack.add(Nav3Route.PromoDialog("detail-${route.productId}")) },
- testTag = nav3TestTag("product_detail_show_promo_dialog"),
- )
- RouteButton(
- "Open Share Sheet",
- onClick = { backStack.add(Nav3Route.ShareSheet(route.productId)) },
- testTag = nav3TestTag("product_detail_open_share_sheet"),
- )
+ if (showOverlays) {
+ RouteButton(
+ "Show Promo Dialog",
+ onClick = { backStack.add(Nav3Route.PromoDialog("detail-${route.productId}")) },
+ testTag = nav3TestTag("product_detail_show_promo_dialog"),
+ )
+ RouteButton(
+ "Open Share Sheet",
+ onClick = { backStack.add(Nav3Route.ShareSheet(route.productId)) },
+ testTag = nav3TestTag("product_detail_open_share_sheet"),
+ )
+ }
RouteButton(
"Go to Checkout",
- onClick = { backStack.add(Nav3Route.Checkout(route.productId)) },
+ onClick = onCheckout,
testTag = nav3TestTag("product_detail_go_to_checkout"),
)
+ extraActions()
},
footerContent = {
RouteButton(
@@ -538,6 +568,7 @@ internal fun ProductDetailRoute(
internal fun CheckoutRoute(
route: Nav3Route.Checkout,
backStack: SnapshotStateList,
+ onCompleteOrder: (orderId: String) -> Unit = { backStack.add(Nav3Route.Confirmation(it)) },
) {
RouteScaffold(
routeSpec = RouteSpecs.checkout,
@@ -548,7 +579,7 @@ internal fun CheckoutRoute(
}
RouteButton(
"Complete Order",
- onClick = { backStack.add(Nav3Route.Confirmation(orderId = "order-${route.productId}")) },
+ onClick = { onCompleteOrder("order-${route.productId}") },
testTag = nav3TestTag("checkout_complete_order"),
)
},
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt
index b9972a6cd76..554a5d3c3d3 100644
--- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt
@@ -26,6 +26,22 @@ internal fun Nav3Route.routeSpec(): RouteSpec =
"Reuses the single-stack shopping flow while simulating a power user who starts their " +
"own manual transactions.",
)
+ Nav3Route.MultiActivity ->
+ RouteSpec(
+ routeName = Nav3Route.MultiActivity.routeName,
+ title = "Multi-Activity",
+ description =
+ "Tests how SentryNavEffect uses NavScopes to coordinate state updates between " +
+ "SentryNavEffect instances. Each Activity owns its own SentryNavEffect and NavDisplay.",
+ )
+ Nav3Route.MultiFragment ->
+ RouteSpec(
+ routeName = Nav3Route.MultiFragment.routeName,
+ title = "Multi-Fragment",
+ description =
+ "Tests SentryNavEffect when each Fragment owns its own SentryNavEffect and NavDisplay: " +
+ "Fragment replace transitions, a dialog overlay, and a ViewPager2 of Fragments.",
+ )
Nav3Route.DeepLink ->
RouteSpec(
routeName = Nav3Route.DeepLink.routeName,
@@ -69,6 +85,8 @@ private fun Nav3Route.toSavedState(): Bundle =
Nav3Route.Landing -> putString("type", "landing")
Nav3Route.SingleStack -> putString("type", "single_stack")
Nav3Route.Custom -> putString("type", "custom")
+ Nav3Route.MultiActivity -> putString("type", "multi_activity")
+ Nav3Route.MultiFragment -> putString("type", "multi_fragment")
Nav3Route.DeepLink -> putString("type", "deep_link")
Nav3Route.ProductList -> putString("type", "product_list")
is Nav3Route.ProductDetail -> {
@@ -108,6 +126,8 @@ private fun Bundle.toNav3Route(): Nav3Route {
"landing" -> Nav3Route.Landing
"single_stack" -> Nav3Route.SingleStack
"custom" -> Nav3Route.Custom
+ "multi_activity" -> Nav3Route.MultiActivity
+ "multi_fragment" -> Nav3Route.MultiFragment
"deep_link" -> Nav3Route.DeepLink
"product_list" -> Nav3Route.ProductList
"product_detail" -> {
@@ -148,6 +168,8 @@ internal fun SnapshotStateList.openScenario(scenario: Nav3Scenario) {
Nav3Scenario.LANDING -> resetTo(Nav3Route.Landing)
Nav3Scenario.SINGLE_STACK -> resetTo(Nav3Route.SingleStack)
Nav3Scenario.CUSTOM -> resetTo(Nav3Route.Custom)
+ Nav3Scenario.MULTI_ACTIVITY -> resetTo(Nav3Route.MultiActivity)
+ Nav3Scenario.MULTI_FRAGMENT -> resetTo(Nav3Route.MultiFragment)
Nav3Scenario.DEEP_LINK -> resetTo(Nav3Route.DeepLink)
Nav3Scenario.MULTIPANE -> resetTo(Nav3Route.Multipane)
Nav3Scenario.MULTIPLE_STACKS -> resetTo(Nav3Route.Multistack)
@@ -211,6 +233,14 @@ internal sealed interface Nav3Route {
override val routeName: String = "Custom"
}
+ data object MultiActivity : Nav3Route {
+ override val routeName: String = "MultiActivity"
+ }
+
+ data object MultiFragment : Nav3Route {
+ override val routeName: String = "MultiFragment"
+ }
+
data object DeepLink : Nav3Route {
override val routeName: String = RouteNames.DEEP_LINK
}
@@ -282,6 +312,8 @@ internal enum class Nav3Scenario(val label: String, val showTab: Boolean = true)
SINGLE_STACK("Single Stack"),
DEEP_LINK("Deep Link"),
CUSTOM("Custom"),
+ MULTI_ACTIVITY("Multi-Activity"),
+ MULTI_FRAGMENT("Multi-Fragment"),
MULTIPANE("Multipane"),
MULTIPLE_STACKS("Multistack"),
PERFORMANCE("Performance"),
@@ -293,6 +325,8 @@ internal val Nav3Scenario.initialRoute: Nav3Route
Nav3Scenario.LANDING -> Nav3Route.Landing
Nav3Scenario.SINGLE_STACK -> Nav3Route.SingleStack
Nav3Scenario.CUSTOM -> Nav3Route.Custom
+ Nav3Scenario.MULTI_ACTIVITY -> Nav3Route.MultiActivity
+ Nav3Scenario.MULTI_FRAGMENT -> Nav3Route.MultiFragment
Nav3Scenario.DEEP_LINK -> Nav3Route.DeepLink
Nav3Scenario.MULTIPANE -> Nav3Route.Multipane
Nav3Scenario.MULTIPLE_STACKS -> Nav3Route.Multistack
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt
index 4a9b77227b1..e68421824b5 100644
--- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt
@@ -73,7 +73,9 @@ internal const val SENTRY_FLUSH_TIMEOUT_MILLIS = 5000L
private const val BACKGROUND_WORK_MILLIS = 1000L
private const val NAV2_SCENARIO_TAG = "sample_nav2_scenario"
-private const val NAV3_SCENARIO_TAG = "sample_nav3_scenario"
+internal const val NAV3_SCENARIO_TAG = "sample_nav3_scenario"
+internal const val NAVIGATION_SAMPLE_ACTIVITY_TAG = "sample_navigation_activity"
+internal const val NAVIGATION_SAMPLE_SESSION_TAG = "sample_navigation_session"
private const val NAVIGATION_SAMPLE_SCENARIO_TAG = "sample_navigation_scenario"
private const val UNKNOWN_NAVIGATION_SCENARIO_LABEL = "Unknown"
private const val ACTIVITY_UI_LOAD_OP = "ui.load"
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt
index db945d45198..7b37bbeacab 100644
--- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt
@@ -35,6 +35,7 @@ class Nav3SetupActivity : AppCompatActivity() {
onLaunch = {
val previousOptions = currentNavigationSampleConfigSnapshot()
configuration.applyToCurrentOptions()
+ NavigationTransactionHistory.startSession()
startActivity(nav3LaunchIntent(configuration, previousOptions))
},
)
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt
index 38cd6d2039b..21d99a50b2c 100644
--- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt
@@ -8,12 +8,19 @@ import io.sentry.protocol.SentrySpan
import io.sentry.protocol.SentryTransaction
import io.sentry.samples.android.SampleBeforeSendTransactionHook
-/** State holder backing the navigation sample transaction history sheet. */
-internal class NavigationTransactionHistory(private val isActive: () -> Boolean) {
+/** Process-wide state holder backing every Nav3 transaction history sheet. */
+internal object NavigationTransactionHistory {
- val transactions = mutableStateListOf()
+ private val store = NavigationTransactionHistoryStore()
+ @Volatile private var sessionGeneration = 0L
+
+ val transactions: List
+ get() = store.transactions
+
+ val sessionId: String
+ get() = sessionGeneration.toString()
- private val mainHandler = Handler(Looper.getMainLooper())
+ private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
private val transactionListener: (SentryTransaction, String?) -> Unit = { transaction, dsn ->
add(transaction, dsn)
}
@@ -24,34 +31,56 @@ internal class NavigationTransactionHistory(private val isActive: () -> Boolean)
SampleBeforeSendTransactionHook.addListener(transactionListener)
}
- fun uninstall() {
+ fun startSession() {
+ install()
+ sessionGeneration++
clear()
- SampleBeforeSendTransactionHook.removeListener(transactionListener)
}
fun clear() {
- transactions.clear()
+ store.clear()
}
private fun add(transaction: SentryTransaction, dsn: String?) {
- if (!isActive()) {
+ val transactionSessionId = transaction.getTag(NAVIGATION_SAMPLE_SESSION_TAG)
+ if (transaction.getTag(NAV3_SCENARIO_TAG) == null || transactionSessionId != sessionId) {
return
}
val trace = transaction.toNavigationTransactionTrace(dsn)
if (Looper.myLooper() == Looper.getMainLooper()) {
- transactions.addMostRecent(trace)
+ store.add(trace)
} else {
mainHandler.post {
- if (isActive()) {
- transactions.addMostRecent(trace)
+ // The session may have changed while this was queued.
+ if (transactionSessionId == sessionId) {
+ store.add(trace)
}
}
}
}
}
+internal class NavigationTransactionHistoryStore(
+ private val transactionLimit: Int = TRANSACTION_HISTORY_LIMIT
+) {
+
+ val transactions = mutableStateListOf()
+
+ fun add(transaction: NavigationTransactionTrace) {
+ transactions.add(0, transaction)
+ while (transactions.size > transactionLimit) {
+ transactions.removeAt(transactions.lastIndex)
+ }
+ }
+
+ fun clear() {
+ transactions.clear()
+ }
+}
+
internal data class NavigationTransactionTrace(
+ val activity: String,
val name: String,
val operation: String,
val eventId: String,
@@ -73,7 +102,7 @@ internal data class NavigationTraceSpan(
val children: List = emptyList(),
)
-private fun SentryTransaction.toNavigationTransactionTrace(
+internal fun SentryTransaction.toNavigationTransactionTrace(
dsn: String?
): NavigationTransactionTrace {
val trace = contexts.trace
@@ -97,6 +126,7 @@ private fun SentryTransaction.toNavigationTransactionTrace(
.sortedBy { span -> span.startOffsetMillis }
return NavigationTransactionTrace(
+ activity = getTag(NAVIGATION_SAMPLE_ACTIVITY_TAG) ?: UNKNOWN_NAVIGATION_ACTIVITY,
name = transaction ?: "",
operation = trace?.operation ?: "transaction",
eventId = eventId,
@@ -109,15 +139,6 @@ private fun SentryTransaction.toNavigationTransactionTrace(
)
}
-private fun MutableList.addMostRecent(
- transaction: NavigationTransactionTrace
-) {
- add(0, transaction)
- while (size > TRANSACTION_HISTORY_LIMIT) {
- removeAt(lastIndex)
- }
-}
-
private fun SentrySpan.toNavigationTraceSpan(
transactionStartTimestamp: Double
): NavigationTraceSpan =
@@ -172,5 +193,6 @@ private fun String.projectIdFromDsn(): String? =
projectId.isNotEmpty() && projectId.all { it.isDigit() }
}
-private const val TRANSACTION_HISTORY_LIMIT = 10
+internal const val TRANSACTION_HISTORY_LIMIT = 25
private const val SENTRY_SAMPLE_ORG_SLUG = "sentry-sdks"
+private const val UNKNOWN_NAVIGATION_ACTIVITY = "Unknown"
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt
index 0e0038083ea..94a643ba7ca 100644
--- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt
@@ -66,7 +66,8 @@ internal fun NavigationTransactionHistorySheet(
modifier = Modifier.testTag(historyTag(sampleName, "title")),
)
Text(
- "Displays the last 10 finished transactions emitted by the SDK. Transactions with child work appear here after their work finishes.",
+ "Displays the last $TRANSACTION_HISTORY_LIMIT finished Nav3 transactions emitted by the SDK. " +
+ "Transactions with child work appear here after their work finishes.",
style = MaterialTheme.typography.bodyMedium,
)
Column(
@@ -133,7 +134,11 @@ private fun TransactionCard(
modifier = Modifier.padding(12.dp).testTag(historyTag(sampleName, "card_${index}_content")),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
- Row(verticalAlignment = Alignment.CenterVertically) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
Column(
modifier = Modifier.weight(1f).testTag(historyTag(sampleName, "card_${index}_summary"))
) {
@@ -152,12 +157,20 @@ private fun TransactionCard(
modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_operation")),
)
}
- Text(
- "Tab: ${transaction.tab}",
- style = MaterialTheme.typography.labelLarge,
- fontFamily = FontFamily.Monospace,
- modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_tab")),
- )
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ transaction.activity,
+ style = MaterialTheme.typography.labelLarge,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_activity")),
+ )
+ Text(
+ "Tab: ${transaction.tab}",
+ style = MaterialTheme.typography.labelLarge,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_tab")),
+ )
+ }
}
TransactionWaterfall(transaction, historyTag(sampleName, "card_${index}_waterfall"))
diff --git a/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml
new file mode 100644
index 00000000000..a74b152b33c
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml
new file mode 100644
index 00000000000..8928cea2bf4
--- /dev/null
+++ b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml
@@ -0,0 +1,6 @@
+
+
+
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
index 918cb756c54..a87670f6c0d 100644
--- a/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml
+++ b/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml
@@ -23,4 +23,7 @@
+
+
+
diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml b/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml
index b01417199a8..73f92d1d263 100644
--- a/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml
+++ b/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml
@@ -20,6 +20,12 @@
+
+
+