From 8530b8da968c0fd6a0a0b86c6e07a0b62235dd58 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 20 Oct 2026 17:10:03 +0200 Subject: [PATCH] feat(sample): [Nav3 Lifecycle 5] Add multi-root scenarios Add runnable multi-Activity and multi-Fragment Navigation 3 scenarios with shared transaction history so lifecycle handoffs can be exercised in the Android sample.\n\nRefs JAVA-274 --- .../sentry-samples-android/build.gradle.kts | 4 + .../src/main/AndroidManifest.xml | 24 + .../android/navigation/nav3/Nav3Activity.kt | 42 +- .../navigation/nav3/Nav3MultiActivity.kt | 491 ++++++++++++++++++ .../navigation/nav3/Nav3MultiFragment.kt | 420 +++++++++++++++ .../navigation/nav3/Nav3RouteScreens.kt | 61 ++- .../android/navigation/nav3/Nav3Routes.kt | 34 ++ .../navigation/nav3/Nav3SampleTracing.kt | 4 +- .../navigation/nav3/Nav3SetupActivity.kt | 1 + .../navigation/nav3/Nav3TransactionHistory.kt | 66 ++- .../nav3/Nav3TransactionHistorySheet.kt | 29 +- .../src/main/res/anim/nav3_slow_fade_in.xml | 7 + .../src/main/res/anim/nav3_slow_hold.xml | 6 + .../src/main/res/values/ids.xml | 3 + .../src/main/res/values/styles.xml | 6 + ...avigationTransactionActivityMappingTest.kt | 40 ++ .../NavigationTransactionHistoryStoreTest.kt | 57 ++ 17 files changed, 1236 insertions(+), 59 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml create mode 100644 sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml create mode 100644 sentry-samples/sentry-samples-android/src/test/java/io/sentry/samples/android/navigation/nav3/NavigationTransactionActivityMappingTest.kt create mode 100644 sentry-samples/sentry-samples-android/src/test/java/io/sentry/samples/android/navigation/nav3/NavigationTransactionHistoryStoreTest.kt 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 @@ + + +