From 884239136ba97b2d32cf6babb72b4695788b8a5f Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 2 Oct 2026 16:47:03 +0200 Subject: [PATCH 1/8] feat(samples): Add Nav3 sample app (JAVA-274) Introduce a sample app for our Sentry Nav3 integration (SentryNavEffect). Key implementation classes are Nav3SetupActivity and Nav3Activity, each of which is closely modeled after the existing Nav2SetupActivity and Nav2Activity that back our Nav2 sample app. Co-Authored-By: Codex --- gradle/libs.versions.toml | 3 + .../sentry-samples-android/README.md | 3 + .../sentry-samples-android/build.gradle.kts | 3 + .../src/main/AndroidManifest.xml | 11 + .../io/sentry/samples/android/MainActivity.kt | 11 + .../samples/android/navigation/README.md | 32 + .../navigation/nav2/Nav2TransactionHistory.kt | 3 +- .../android/navigation/nav3/Nav3Activity.kt | 609 ++++++++ .../nav3/Nav3BottomSheetSceneStrategy.kt | 172 +++ .../navigation/nav3/Nav3CustomTransactions.kt | 222 +++ .../navigation/nav3/Nav3Performance.kt | 279 ++++ .../nav3/Nav3PerformanceControls.kt | 1295 +++++++++++++++++ .../navigation/nav3/Nav3RouteScreens.kt | 730 ++++++++++ .../android/navigation/nav3/Nav3Routes.kt | 293 ++++ .../navigation/nav3/Nav3SampleConfig.kt | 139 ++ .../navigation/nav3/Nav3SampleTheme.kt | 60 + .../navigation/nav3/Nav3SampleTracing.kt | 77 + .../navigation/nav3/Nav3SetupActivity.kt | 49 + .../android/navigation/nav3/Nav3TopBar.kt | 172 +++ .../navigation/nav3/Nav3TransactionHistory.kt | 176 +++ .../nav3/Nav3TransactionHistorySheet.kt | 368 +++++ 21 files changed, 4706 insertions(+), 1 deletion(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/README.md create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3BottomSheetSceneStrategy.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleConfig.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTheme.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TopBar.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c73ae6a250..2e9ddb0bd9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ animalsniffer = "2.0.1" apollo = "2.5.9" androidxLifecycle = "2.2.0" androidxNavigation = "2.9.8" +androidxNavigation3 = "1.1.5" androidxTestCore = "1.7.0" androidxCompose = "1.9.5" asyncProfiler = "4.4" @@ -109,6 +110,8 @@ androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidxNavigation" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" } diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md index 99d0edcd1c3..b065b91e955 100644 --- a/sentry-samples/sentry-samples-android/README.md +++ b/sentry-samples/sentry-samples-android/README.md @@ -21,6 +21,9 @@ You can also apply the [Sentry Android Gradle Plugin](https://github.com/getsent In Android Studio, add `useSagp=` (empty value) to `gradle.properties`, or pass `-PuseSagp` as a Gradle project property. +For LLM-driven verification, use `adb` to inspect and interact with the running emulator. Compose-based screens often expose stable +`Modifier.testTag()` values, which can be used with the UI hierarchy dump to locate controls reliably. + ## Build modes ### With or without SAGP diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 40e5bbc9822..4fee4a1b92a 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -187,6 +187,7 @@ dependencies { implementation(projects.sentryAndroid) implementation(projects.sentryAndroidFragment) implementation(projects.sentryAndroidNavigation) + implementation(projects.sentryAndroidNavigation3) implementation(projects.sentryAndroidSqlite) implementation(projects.sentryAndroidTimber) implementation(projects.sentryCompose) @@ -212,6 +213,8 @@ dependencies { implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.recyclerview) implementation(libs.androidx.browser) implementation(libs.androidx.room3.runtime) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 9b103274623..4e80ddfd865 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -112,6 +112,17 @@ android:screenOrientation="portrait" android:theme="@style/AppTheme.Main" /> + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index 54214b8ff55..9660f52ed36 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -93,6 +93,7 @@ import io.sentry.compose.SentryTraced import io.sentry.protocol.Feedback import io.sentry.protocol.User import io.sentry.samples.android.memory.MemoryLimiterActivity +import io.sentry.samples.android.navigation.nav3.Nav3SetupActivity import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -912,6 +913,16 @@ fun IntegrationsScreen() { } } } + item { + SentryTraced("open_nav3_activity") { + OutlinedButton( + onClick = { activity.startActivity(Intent(activity, Nav3SetupActivity::class.java)) }, + modifier = Modifier, + ) { + Text("Open Nav3 Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } item { SentryTraced("open_sample_fragment") { OutlinedButton( diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/README.md b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/README.md new file mode 100644 index 00000000000..848720b0198 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/README.md @@ -0,0 +1,32 @@ +# Nav2 and Nav3 Samples + +## Nav Sample Transaction Tips + +When inspecting route-level transactions from `Nav2Activity` or `Nav3Activity`, note that the sample app also enables automatic Activity +tracing by default. That can produce overlapping `ui.load` transactions for the Activity itself, which may make it harder to isolate the +navigation transactions generated by the Nav2 or Nav3 integrations. + +If you want the route transactions to be the primary trace surface while testing those sample screens, temporarily disable automatic +Activity tracing in `src/main/AndroidManifest.xml`: + +```xml + +``` + +This is mainly useful for debugging and comparison in the sample app. It is not a general recommendation for production apps. + +## Navigation Performance Scenarios + +Both navigation samples include an interactive performance scenario for exercising stack mutations and route rendering. + +### Nav2 + +Open Nav2 from the integrations screen, select the `Performance` tab, and use the controls to build a stack or replace the top destination. +The sample also supports automatic recompositions and navigation for sustained stress testing. + +### Nav3 + +Open Nav3 from the integrations screen, select the `Performance` tab, and use the presets or performance controls to run a scenario with the +desired stack depth, workload, integration mode, and argument shape. diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2TransactionHistory.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2TransactionHistory.kt index a846e51b8e9..f64a78b051d 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2TransactionHistory.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2TransactionHistory.kt @@ -76,7 +76,8 @@ internal data class Nav2TraceSpan( private fun SentryTransaction.toNav2TransactionTrace(dsn: String?): Nav2TransactionTrace { val trace = contexts.trace val startTimestamp = startTimestamp - val endTimestamp = timestamp ?: startTimestamp + val endTimestamp = + timestamp ?: spans.mapNotNull { span -> span.timestamp }.maxOrNull() ?: startTimestamp val durationMillis = ((endTimestamp - startTimestamp) * 1_000.0).coerceAtLeast(0.0) val rootSpanId = trace?.spanId?.toString() val traceId = trace?.traceId?.toString().orEmpty() 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 new file mode 100644 index 00000000000..9f975bf2f48 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt @@ -0,0 +1,609 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.annotation.SuppressLint +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.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material3.AlertDialog +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.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.platform.LocalContext +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +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.NavigationSampleConfigSnapshot +import io.sentry.samples.android.navigation.common.RouteWorkOption +import io.sentry.samples.android.navigation.common.applyToCurrentOptions +import io.sentry.samples.android.navigation.common.currentNavigationSampleConfigSnapshot +import io.sentry.samples.android.navigation.common.hasOnlyActivityUiLoadTransactions +import io.sentry.samples.android.navigation.common.showRouteWorkDialog +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Sample app Activity for testing Sentry's + * [Nav3](https://developer.android.com/guide/navigation/navigation-3) integrations. + * + * Look at Google's [nav3-recipes](https://github.com/android/nav3-recipes) for helpful patterns to + * test against. (This Activity doesn't address all of them yet, so update its implementation as + * needed.) + */ +class Nav3Activity : ComponentActivity() { + + private lateinit var previousConfig: NavigationSampleConfigSnapshot + + private val performanceState = + Nav3PerformanceState( + measureRenderLatency = true, + diagnosticsSurfaceName = "Nav3", + ) + + 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 + + private var showCrashConfirmation by mutableStateOf(false) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + previousConfig = + intent.previousNav3SampleConfigSnapshot(currentNavigationSampleConfigSnapshot()) + + val configuration = intent.nav3SampleConfig() + configuration.applyToCurrentOptions() + showActivityUiLoadTransactionDelayMessage = configuration.hasOnlyActivityUiLoadTransactions + + if (!configuration.enableActivityUiLoadTransaction) { + // Cancel the already-started Activity transaction before Compose installs SentryNavEffect, + // otherwise the initial /Landing route transaction gets preempted by the lingering ui.load. + cancelCurrentActivityUiLoadTransaction() + } + + transactionHistory.install() + + setContent { + Nav3SampleTheme { + Nav3SampleApp( + performanceState = performanceState, + configuration = configuration, + transactions = transactionHistory.transactions, + showActivityUiLoadTransactionDelayMessage = showActivityUiLoadTransactionDelayMessage, + routeWorkOptions = routeWorkOptions, + showTransactionHistorySheet = showTransactionHistorySheet, + showCrashConfirmation = showCrashConfirmation, + onShowTransactionHistorySheet = { showTransactionHistorySheet = true }, + onDismissTransactionHistorySheet = { showTransactionHistorySheet = false }, + onShowRouteWorkSettings = { showRouteWorkSettings() }, + onShowCrashConfirmation = { showCrashConfirmation = true }, + onDismissCrashConfirmation = { showCrashConfirmation = false }, + onOpenTransaction = { url -> openTransactionInSentry(url) }, + onDumpTransactionUrl = { url -> dumpTransactionUrl(url) }, + onCopyTransactionUrl = { url -> copyTransactionUrl(url) }, + ) + } + } + } + + override fun onStart() { + super.onStart() + isTransactionHistoryActive = true + } + + override fun onStop() { + isTransactionHistoryActive = false + performanceState.stopAutomaticWork() + super.onStop() + } + + override fun onDestroy() { + if (isFinishing) { + previousConfig.applyToCurrentOptions() + } + transactionHistory.uninstall() + super.onDestroy() + } + + private fun openTransactionInSentry(url: String) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + + private fun dumpTransactionUrl(url: String) { + Log.i(NAV3_TAG, "Sentry transaction URL: $url") + Toast.makeText(this, "Dumped transaction URL to logcat.", Toast.LENGTH_SHORT).show() + } + + private fun copyTransactionUrl(url: String) { + val clipboard = getSystemService(ClipboardManager::class.java) + clipboard.setPrimaryClip(ClipData.newPlainText("Sentry transaction URL", url)) + Toast.makeText(this, "Copied transaction URL to clipboard.", Toast.LENGTH_SHORT).show() + } + + private fun showRouteWorkSettings() { + showRouteWorkDialog(this, routeWorkOptions) { selectedOptions -> + routeWorkOptions = selectedOptions + } + } +} + +@SuppressLint("ContextCastToActivity") +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun Nav3SampleApp( + performanceState: Nav3PerformanceState, + configuration: NavigationSampleConfig, + transactions: List, + showActivityUiLoadTransactionDelayMessage: Boolean, + routeWorkOptions: Set, + showTransactionHistorySheet: Boolean, + showCrashConfirmation: Boolean, + onShowTransactionHistorySheet: () -> Unit, + onDismissTransactionHistorySheet: () -> Unit, + onShowRouteWorkSettings: () -> Unit, + onShowCrashConfirmation: () -> Unit, + onDismissCrashConfirmation: () -> Unit, + onOpenTransaction: (String) -> Unit, + onDumpTransactionUrl: (String) -> Unit, + onCopyTransactionUrl: (String) -> Unit, +) { + val activity = LocalContext.current as? ComponentActivity + val initialScenario = + if (configuration.enableActivityUiLoadTransaction) { + Nav3Scenario.SINGLE_STACK + } else { + Nav3Scenario.LANDING + } + val backStack = rememberSaveableNav3BackStack(initialScenario.initialRoute) + val dialogSceneStrategy = remember { Nav3DialogSceneStrategy() } + val bottomSheetSceneStrategy = remember { Nav3BottomSheetSceneStrategy() } + + var enableNavigationBreadcrumbs by remember { + mutableStateOf(configuration.enableNavigationBreadcrumbs) + } + var enableNavigationTransactions by remember { + mutableStateOf(configuration.enableNavigationTransactions) + } + var captureBackStack by remember { mutableStateOf(configuration.captureBackStack) } + var maxCapturedBackStackEntries by remember { + mutableIntStateOf(configuration.maxCapturedBackStackEntries) + } + var selectedScenario by rememberSaveable { mutableStateOf(initialScenario) } + var customTransactionMode by rememberSaveable { + mutableStateOf(Nav3CustomTransactionMode.PER_SCREEN) + } + var asyncBrowseProductsJob by remember { mutableStateOf(null) } + var isAsyncBrowseProductsRunning by remember { mutableStateOf(false) } + val performanceScope = rememberCoroutineScope() + val customTransactionsScope = rememberCoroutineScope() + val customTransactionController = remember { Nav3CustomTransactionController() } + + if (selectedScenario == Nav3Scenario.PERFORMANCE) { + @Suppress("UNUSED_EXPRESSION") performanceState.recompositionTick + } + + val isPerformanceScenario = selectedScenario == Nav3Scenario.PERFORMANCE + val sentryBackStack = if (isPerformanceScenario) backStack.toList() else backStack + val integrationMode = performanceState.integrationMode + val effectiveCaptureBackStack = + if (isPerformanceScenario) integrationMode.captureBackStack else captureBackStack + val sentryNavOptions = + remember( + enableNavigationBreadcrumbs, + enableNavigationTransactions, + effectiveCaptureBackStack, + maxCapturedBackStackEntries, + ) { + SentryNavOptions { + this.enableNavigationBreadcrumbs = enableNavigationBreadcrumbs + this.enableNavigationTransactions = enableNavigationTransactions + this.captureBackStack = effectiveCaptureBackStack + this.maxCapturedBackStackEntries = maxCapturedBackStackEntries + } + } + val performanceExtractorMode = performanceState.extractorMode + val performanceArgumentMode = performanceState.argumentMode + val backStackEntryMapper = + remember( + isPerformanceScenario, + performanceExtractorMode, + performanceArgumentMode, + integrationMode.includeArguments, + ) { + { route: Nav3Route -> + val name = + if (isPerformanceScenario) { + performanceState.recordNameExtraction("Nav3Stress.nameExtraction") { + consumeNav3PerformanceExtractorWork( + performanceExtractorMode, + route.performanceSeed, + ) + route.routeName + } + } else { + route.routeName + } + + val arguments = + if (isPerformanceScenario && !integrationMode.includeArguments) { + null + } else if (isPerformanceScenario) { + performanceState.recordArgumentExtraction("Nav3Stress.argumentsExtraction") { + consumeNav3PerformanceExtractorWork( + performanceExtractorMode, + route.performanceSeed, + ) + if (route is Nav3Route.Performance) { + nav3PerformanceArguments( + performanceArgumentMode, + route.index, + route.generation, + ) + } else { + route.arguments + } + } + } else { + route.arguments + } + + SentryBackStackEntry(name = name, arguments = arguments) + } + } + + if (!isPerformanceScenario || integrationMode != Nav3PerformanceIntegrationMode.DISABLED) { + Nav3CustomTransactionEffect( + selectedScenario = selectedScenario, + mode = customTransactionMode, + backStack = backStack, + controller = customTransactionController, + ) + + val nameExtractorCallsBefore = performanceState.nameExtractorCalls + val argumentsExtractorCallsBefore = performanceState.argumentsExtractorCalls + val extractorNanosBefore = + performanceState.nameExtractorNanos + performanceState.argumentsExtractorNanos + val startedAtNanos = System.nanoTime() + + SentryNavEffect( + backStack = sentryBackStack, + backStackEntryMapper = backStackEntryMapper, + options = sentryNavOptions, + ) + + if (isPerformanceScenario) { + performanceState.recordSentryNavEffect( + durationNanos = System.nanoTime() - startedAtNanos, + nameExtractorCallsBefore = nameExtractorCallsBefore, + argumentsExtractorCallsBefore = argumentsExtractorCallsBefore, + extractorNanosBefore = extractorNanosBefore, + processedCall = true, + resolvedEntryCount = + if (integrationMode.captureBackStack) { + minOf(sentryBackStack.size, maxCapturedBackStackEntries) + } else { + 0 + }, + ) + } + } + + SideEffect { performanceState.recordComposition() } + + val applyPerformancePreset: (Nav3PerformancePreset) -> Unit = { preset -> + if (!performanceState.performanceRunActive) { + performanceScope.launch { + prepareNav3PerformancePreset( + preset = preset, + state = performanceState, + backStack = backStack, + onMaxCapturedBackStackEntriesChange = { maxCapturedBackStackEntries = it }, + ) + } + } + } + val runPerformance: (Nav3PerformanceRun) -> Unit = { run -> + if (!performanceState.performanceRunActive) { + performanceScope.launch { + runNav3Performance( + run = run, + state = performanceState, + backStack = backStack, + ) + } + } + } + + LaunchedEffect(selectedScenario, sentryBackStack.lastOrNull()) { + tagCurrentNavigationSampleScenario(selectedScenario.label) + } + + LaunchedEffect(selectedScenario, customTransactionMode) { + if ( + selectedScenario != Nav3Scenario.CUSTOM || + customTransactionMode != Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION + ) { + asyncBrowseProductsJob?.cancel() + asyncBrowseProductsJob = null + isAsyncBrowseProductsRunning = false + } + } + + Scaffold( + modifier = Modifier.fillMaxSize().safeDrawingPadding(), + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Nav3TopBar( + backStack = backStack, + selectedScenario = selectedScenario, + maxCapturedBackStackEntries = maxCapturedBackStackEntries, + onTransactionHistoryClick = onShowTransactionHistorySheet, + onRouteWorkSettingsClick = onShowRouteWorkSettings, + onScenarioSelected = { scenario -> + if (scenario != Nav3Scenario.PERFORMANCE && performanceState.performanceRunActive) { + performanceState.cancelPerformanceRun() + } + selectedScenario = scenario + performanceState.stopAutomaticWork() + backStack.openScenario(scenario) + if (scenario == Nav3Scenario.PERFORMANCE) { + performanceState.resetCounters() + performanceState.markNavigationMutation() + } + }, + ) + }, + bottomBar = { + SentryControls( + onCaptureException = { captureSampleException("Nav3") }, + onCrashApp = onShowCrashConfirmation, + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier.fillMaxSize() + .padding(innerPadding) + .background(MaterialTheme.colorScheme.background) + ) { + NavDisplay( + backStack = backStack, + modifier = + Modifier.fillMaxSize().drawWithContent { + drawContent() + performanceState.recordFirstDraw() + }, + onBack = { + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + activity?.finish() + } + }, + sceneStrategies = listOf(dialogSceneStrategy, bottomSheetSceneStrategy), + transitionSpec = { + fadeIn(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS)) + .togetherWith(fadeOut(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS))) + }, + popTransitionSpec = { + fadeIn(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS)) + .togetherWith(fadeOut(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS))) + }, + entryProvider = + entryProvider { + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + SingleStackRoute(backStack) + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + CustomRoute( + mode = customTransactionMode, + onModeSelected = { customTransactionMode = it }, + isAsyncBrowseProductsRunning = isAsyncBrowseProductsRunning, + onBrowseProducts = { + if (customTransactionMode == Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION) { + if (!isAsyncBrowseProductsRunning) { + customTransactionController.startAsyncBrowseProductsTransaction() + isAsyncBrowseProductsRunning = true + asyncBrowseProductsJob = customTransactionsScope.launch { + val span = + io.sentry.Sentry.getSpan() + ?.startChild( + "test.navigation.async_browse_products", + "Nav3 Custom async browse products", + ) + try { + delay(250) + backStack.add(Nav3Route.ProductList) + } finally { + span?.finish() + isAsyncBrowseProductsRunning = false + asyncBrowseProductsJob = null + } + } + } + } else { + backStack.add(Nav3Route.ProductList) + } + }, + ) + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + LandingRoute() + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + DeepLinkRoute(backStack) + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductListRoute(backStack) + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductDetailRoute(route, backStack) + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + CheckoutRoute(route, backStack) + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ConfirmationRoute( + route = route, + backStack = backStack, + rootRoute = + if (selectedScenario == Nav3Scenario.CUSTOM) { + Nav3Route.Custom + } else { + Nav3Route.SingleStack + }, + ) + } + } + + entry(metadata = Nav3DialogSceneStrategy.dialog()) { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + PromoDialogRoute( + route = route, + backStack = backStack, + onCaptureException = { captureSampleException("Nav3") }, + onCrashApp = onShowCrashConfirmation, + ) + } + } + entry(metadata = Nav3BottomSheetSceneStrategy.bottomSheet()) { + route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ShareSheetRoute( + route = route, + backStack = backStack, + onCaptureException = { captureSampleException("Nav3") }, + onCrashApp = onShowCrashConfirmation, + ) + } + } + + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + FutureRoute(routeName = "Multipane", scenario = "multipane") + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + FutureRoute(routeName = "Multiple Stacks", scenario = "multistack") + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + Nav3PerformanceRoute( + route = route, + backStack = backStack, + performanceState = performanceState, + maxCapturedBackStackEntries = maxCapturedBackStackEntries, + onMaxCapturedBackStackEntriesChange = { maxCapturedBackStackEntries = it }, + onApplyPreset = applyPerformancePreset, + onRunPerformance = runPerformance, + ) + } + } + }, + ) + } + } + + if (showTransactionHistorySheet) { + NavigationTransactionHistorySheet( + sampleName = "Nav3", + transactions = transactions, + showActivityUiLoadTransactionDelayMessage = showActivityUiLoadTransactionDelayMessage, + onDismissRequest = onDismissTransactionHistorySheet, + onOpenTransaction = onOpenTransaction, + onDumpTransactionUrl = onDumpTransactionUrl, + onCopyTransactionUrl = onCopyTransactionUrl, + ) + } + + if (showCrashConfirmation) { + AlertDialog( + onDismissRequest = onDismissCrashConfirmation, + title = { Text("Crash app?") }, + text = { Text("This will throw an uncaught exception and close the sample app.") }, + dismissButton = { + TextButton(onClick = onDismissCrashConfirmation) { Text("Cancel") } + }, + confirmButton = { + TextButton( + onClick = { + onDismissCrashConfirmation() + crashSampleApp("Nav3") + } + ) { + Text("Crash") + } + }, + ) + } +} + +private const val NAV3_TAG = "Nav3Activity" +private const val COMPOSE_ROUTE_TRANSITION_MILLIS = 350 diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3BottomSheetSceneStrategy.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3BottomSheetSceneStrategy.kt new file mode 100644 index 00000000000..2ff41d96845 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3BottomSheetSceneStrategy.kt @@ -0,0 +1,172 @@ +package io.sentry.samples.android.navigation.nav3 + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.get +import androidx.navigation3.runtime.metadata +import androidx.navigation3.scene.OverlayScene +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope + +/** + * Sample-only scene strategies for rendering lightweight overlays inside the Nav3 content frame. + */ +internal data class Nav3OverlayScene( + override val key: T, + override val previousEntries: List>, + override val overlaidEntries: List>, + private val entry: NavEntry, + private val onBack: () -> Unit, + private val alignment: Alignment, + private val enterTransition: @Composable () -> androidx.compose.animation.EnterTransition, + private val cardModifier: Modifier, +) : OverlayScene { + + override val entries: List> = listOf(entry) + + override val content: @Composable (() -> Unit) = { + val visibleState = rememberOverlayVisibleState() + + // NavDisplay bases its system-back pop count on the non-overlay scene beneath this custom + // in-content overlay. Consume Back here so a single press removes only the overlay entry. + BackHandler(onBack = onBack) + + Box(modifier = Modifier.fillMaxSize()) { + AnimatedOverlayScrim(visibleState = visibleState, onBack = onBack) + AnimatedVisibility( + visibleState = visibleState, + enter = enterTransition(), + modifier = Modifier.align(alignment), + ) { + Card( + modifier = cardModifier, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + entry.Content() + } + } + } + } +} + +/** Displays entries with [bottomSheet] metadata as bottom-sheet overlays. */ +internal class Nav3BottomSheetSceneStrategy : SceneStrategy { + + private val strategy = + Nav3OverlaySceneStrategy( + metadataKey = BottomSheetKey, + alignment = Alignment.BottomCenter, + enterTransition = { + fadeIn(animationSpec = tween(OVERLAY_ENTER_MILLIS)) + slideInVertically { it } + }, + cardModifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + ) + + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + return with(strategy) { calculateScene(entries) } + } + + internal companion object { + fun bottomSheet() = metadata { put(BottomSheetKey, true) } + + object BottomSheetKey : NavMetadataKey + } +} + +/** Displays marked Nav3 entries as in-content dialog overlays. */ +@Composable +private fun rememberOverlayVisibleState(): MutableTransitionState = remember { + MutableTransitionState(false).apply { targetState = true } +} + +@Composable +private fun AnimatedOverlayScrim( + visibleState: MutableTransitionState, + onBack: () -> Unit, +) { + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween(OVERLAY_ENTER_MILLIS)), + ) { + Box( + modifier = + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.32f)).clickable { onBack() } + ) + } +} + +private const val OVERLAY_ENTER_MILLIS = 120 + +private class Nav3OverlaySceneStrategy( + private val metadataKey: NavMetadataKey, + private val alignment: Alignment, + private val enterTransition: @Composable () -> androidx.compose.animation.EnterTransition, + private val cardModifier: Modifier, +) { + + fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + val overlayEntry = entries.lastOrNull() ?: return null + overlayEntry.metadata[metadataKey] ?: return null + val baseEntries = entries.dropLast(1) + @Suppress("UNCHECKED_CAST") val sceneKey = overlayEntry.contentKey as T + + return Nav3OverlayScene( + key = sceneKey, + previousEntries = baseEntries, + overlaidEntries = baseEntries, + entry = overlayEntry, + onBack = onBack, + alignment = alignment, + enterTransition = enterTransition, + cardModifier = cardModifier, + ) + } +} + +/** Displays entries with [dialog] metadata as dialog overlays inside the NavDisplay frame. */ +internal class Nav3DialogSceneStrategy : SceneStrategy { + + private val strategy = + Nav3OverlaySceneStrategy( + metadataKey = DialogKey, + alignment = Alignment.Center, + enterTransition = { + fadeIn(animationSpec = tween(OVERLAY_ENTER_MILLIS)) + scaleIn(initialScale = 0.96f) + }, + cardModifier = Modifier.fillMaxWidth().padding(horizontal = 40.dp), + ) + + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + return with(strategy) { calculateScene(entries) } + } + + internal companion object { + fun dialog() = metadata { put(DialogKey, true) } + + object DialogKey : NavMetadataKey + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt new file mode 100644 index 00000000000..4ff777238b9 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt @@ -0,0 +1,222 @@ +package io.sentry.samples.android.navigation.nav3 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import io.sentry.ITransaction +import io.sentry.Sentry +import io.sentry.SpanStatus +import io.sentry.TransactionOptions + +internal enum class Nav3CustomTransactionMode( + val label: String, + val description: String, +) { + PER_SCREEN( + label = "Per Screen", + description = + "Starts a custom transaction for every destination so Nav3 route work runs under app-owned " + + "screen-level transactions.", + ), + WHOLE_FLOW( + label = "Whole Flow", + description = + "Keeps one custom transaction open for the whole shopping journey until the flow returns " + + "to the Custom home screen.", + ), + LINGERING( + label = "Lingering", + description = + "Starts one custom transaction and intentionally leaves it active across later routes to " + + "simulate a stale power-user transaction.", + ), + ASYNC_FROM_USER_ACTION( + label = "Async From User Action", + description = + "Starts a custom transaction from the Browse Products button, waits for async work, then " + + "navigates to Product List while the manual transaction stays active.", + ), +} + +@Composable +internal fun Nav3CustomTransactionEffect( + selectedScenario: Nav3Scenario, + mode: Nav3CustomTransactionMode, + backStack: List, + controller: Nav3CustomTransactionController, +) { + val backStackSnapshot = backStack.toList() + + DisposableEffect(selectedScenario, mode, backStackSnapshot) { + controller.onBackStackChanged( + isCustomScenario = selectedScenario == Nav3Scenario.CUSTOM, + mode = mode, + backStack = backStackSnapshot, + ) + onDispose {} + } + + DisposableEffect(controller) { onDispose { controller.cleanup() } } +} + +internal class Nav3CustomTransactionController { + + private var activeTransaction: ITransaction? = null + private var activeMode: Nav3CustomTransactionMode? = null + private var activeRouteName: String? = null + + fun onBackStackChanged( + isCustomScenario: Boolean, + mode: Nav3CustomTransactionMode, + backStack: List, + ) { + if (!isCustomScenario) { + cleanup() + return + } + + val currentRoute = + backStack.lastOrNull() + ?: run { + cleanup() + return + } + + when (mode) { + Nav3CustomTransactionMode.PER_SCREEN -> handlePerScreen(currentRoute) + Nav3CustomTransactionMode.WHOLE_FLOW -> handleWholeFlow(currentRoute) + Nav3CustomTransactionMode.LINGERING -> handleLingering(currentRoute) + Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION -> handleAsyncFromUserAction(currentRoute) + } + } + + fun startAsyncBrowseProductsTransaction() { + finishActiveTransaction() + activeTransaction = + startCustomTransaction( + name = "custom.tap_to_browse_products", + operation = "ui.action", + mode = Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION, + routeName = Nav3Route.Custom.routeName, + ) + activeMode = Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION + activeRouteName = Nav3Route.Custom.routeName + activeTransaction?.setData("sample.async_trigger", "browse_products") + } + + fun cleanup() { + finishActiveTransaction() + } + + private fun handlePerScreen(currentRoute: Nav3Route) { + if ( + activeMode == Nav3CustomTransactionMode.PER_SCREEN && + activeRouteName == currentRoute.routeName + ) { + return + } + + finishActiveTransaction() + activeTransaction = + startCustomTransaction( + name = "custom.${currentRoute.routeName.lowercase()}_screen", + operation = "ui.screen.manual", + mode = Nav3CustomTransactionMode.PER_SCREEN, + routeName = currentRoute.routeName, + ) + activeMode = Nav3CustomTransactionMode.PER_SCREEN + activeRouteName = currentRoute.routeName + } + + private fun handleWholeFlow(currentRoute: Nav3Route) { + if (currentRoute == Nav3Route.Custom) { + if (activeMode == Nav3CustomTransactionMode.WHOLE_FLOW) { + finishActiveTransaction() + } + return + } + + if (activeMode != Nav3CustomTransactionMode.WHOLE_FLOW || activeTransaction == null) { + finishActiveTransaction() + activeTransaction = + startCustomTransaction( + name = "custom.checkout_flow", + operation = "ui.flow.manual", + mode = Nav3CustomTransactionMode.WHOLE_FLOW, + routeName = currentRoute.routeName, + ) + activeMode = Nav3CustomTransactionMode.WHOLE_FLOW + } + + activeRouteName = currentRoute.routeName + activeTransaction?.setData("sample.current_route", currentRoute.routeName) + } + + private fun handleLingering(currentRoute: Nav3Route) { + if (activeMode == Nav3CustomTransactionMode.LINGERING && activeTransaction != null) { + activeTransaction?.setData("sample.current_route", currentRoute.routeName) + return + } + + finishActiveTransaction() + activeTransaction = + startCustomTransaction( + name = "custom.lingering_navigation_transaction", + operation = "ui.flow.manual", + mode = Nav3CustomTransactionMode.LINGERING, + routeName = currentRoute.routeName, + ) + activeMode = Nav3CustomTransactionMode.LINGERING + activeRouteName = currentRoute.routeName + } + + private fun handleAsyncFromUserAction(currentRoute: Nav3Route) { + if (activeMode != Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION) { + finishActiveTransaction() + return + } + + val transaction = activeTransaction ?: return + activeMode = Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION + activeRouteName = currentRoute.routeName + transaction.setData("sample.current_route", currentRoute.routeName) + + if (currentRoute != Nav3Route.Custom && currentRoute !is Nav3Route.ProductList) { + finishActiveTransaction() + } + } + + private fun startCustomTransaction( + name: String, + operation: String, + mode: Nav3CustomTransactionMode, + routeName: String, + ): ITransaction { + val options = + TransactionOptions().also { + it.isBindToScope = true + it.isWaitForChildren = true + it.idleTimeout = null + } + return Sentry.startTransaction(name, operation, options).apply { + setTag("sample_nav3_scenario", Nav3Scenario.CUSTOM.label) + setTag("sample_nav3_custom_mode", mode.label) + setData("sample.custom_transaction", true) + setData("sample.current_route", routeName) + } + } + + private fun finishActiveTransaction() { + val transaction = activeTransaction ?: return + transaction.finish(transaction.status ?: SpanStatus.OK) + Sentry.configureScope { scope -> + scope.withTransaction { current -> + if (current == transaction) { + scope.clearTransaction() + } + } + } + activeTransaction = null + activeMode = null + activeRouteName = null + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt new file mode 100644 index 00000000000..3e77b9afa63 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt @@ -0,0 +1,279 @@ +package io.sentry.samples.android.navigation.nav3 + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException + +@Composable +internal fun Nav3PerformanceRoute( + route: Nav3Route.Performance, + backStack: SnapshotStateList, + performanceState: Nav3PerformanceState, + maxCapturedBackStackEntries: Int, + onMaxCapturedBackStackEntriesChange: (Int) -> Unit, + onApplyPreset: (Nav3PerformancePreset) -> Unit, + onRunPerformance: (Nav3PerformanceRun) -> Unit, +) { + val performanceRunActive = performanceState.performanceRunActive + if (performanceRunActive) { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Performance run", style = MaterialTheme.typography.headlineMedium) + Text(performanceState.performanceStatus, style = MaterialTheme.typography.bodyLarge) + Text("Diagnostics will be published when the run completes.") + } + return + } + + LaunchedEffect(route) { performanceState.markDestinationChange() } + + Nav3PerformancePanel( + title = "Performance", + description = + "Stress SentryNavEffect with deep stacks, unrelated recompositions, route extraction, and " + + "argument sanitization. Use Perfetto sections like SentryNavEffect.onBackStackChanged and " + + "the Nav3Stress markers to inspect hot paths.", + currentRoute = "/${route.previewName}", + backStack = nav3PerformanceBackStackPreview(backStack.map { entry -> "/${entry.previewName}" }), + state = performanceState, + showExtractorControls = true, + onBuildStack = { + traceNav3PerformanceSection("Nav3Stress.buildStack") { + performanceState.beginNavigationOperation() + backStack.openPerformanceStack( + depth = performanceState.stackDepth, + generation = performanceState.nextGeneration(), + ) + performanceState.markNavigationMutation() + } + }, + onMutateLowerEntry = { + traceNav3PerformanceSection("Nav3Stress.mutateLowerEntry") { + performanceState.beginNavigationOperation() + backStack.mutatePerformanceLowerEntry(performanceState.nextGeneration()) + performanceState.markNavigationMutation() + } + }, + onReplaceTop = { + traceNav3PerformanceSection("Nav3Stress.replaceTop") { + performanceState.beginNavigationOperation() + backStack.replacePerformanceTop(performanceState.nextGeneration()) + performanceState.markNavigationMutation() + } + }, + nav3Controls = + Nav3PerformancePanelControls( + actualStackEntries = backStack.size, + maxCapturedBackStackEntries = maxCapturedBackStackEntries, + onMaxCapturedBackStackEntriesChange = onMaxCapturedBackStackEntriesChange, + onApplyPreset = onApplyPreset, + onRunPerformance = onRunPerformance, + ), + ) +} + +internal suspend fun prepareNav3PerformancePreset( + preset: Nav3PerformancePreset, + state: Nav3PerformanceState, + backStack: SnapshotStateList, + onMaxCapturedBackStackEntriesChange: (Int) -> Unit, +) { + state.startPerformanceRun("Preparing ${preset.label}") + try { + state.stackDepth = preset.stackDepth + state.extractorMode = preset.extractorMode + state.argumentMode = preset.argumentMode + state.updateIntegrationMode(preset.integrationMode) + onMaxCapturedBackStackEntriesChange(preset.maxCapturedBackStackEntries) + backStack.openPerformanceStack(preset.stackDepth, state.nextGeneration()) + awaitNav3PerformanceFrames() + if ( + !runNav3PerformanceIterations( + iterationCount = PERFORMANCE_WARM_UP_ITERATIONS, + run = Nav3PerformanceRun.TOP_REPLACEMENTS, + state = state, + backStack = backStack, + ) + ) { + return + } + state.resetCounters() + state.suppressNextDestinationChange() + state.cancelPerformanceRun(status = "Ready: ${preset.label}") + } catch (e: CancellationException) { + state.cancelPerformanceRun() + throw e + } +} + +internal suspend fun runNav3Performance( + run: Nav3PerformanceRun, + state: Nav3PerformanceState, + backStack: SnapshotStateList, + warmUpOnly: Boolean = false, + skipWarmUp: Boolean = false, +) { + if (!skipWarmUp) { + state.startPerformanceRun("Warming up") + } + try { + if (run == Nav3PerformanceRun.AB_COMPARISON) { + runNav3PerformanceAbComparison(state, backStack) + return + } + + if ( + !skipWarmUp && + !runNav3PerformanceIterations( + iterationCount = PERFORMANCE_WARM_UP_ITERATIONS, + run = run, + state = state, + backStack = backStack, + ) + ) { + return + } + if (warmUpOnly) { + state.finishWarmUp() + return + } + state.startMeasuredIterations() + if (!skipWarmUp) { + state.updatePerformanceStatus("Measuring $PERFORMANCE_MEASURED_ITERATIONS iterations") + } + if ( + !runNav3PerformanceIterations( + iterationCount = PERFORMANCE_MEASURED_ITERATIONS, + run = run, + state = state, + backStack = backStack, + ) + ) { + return + } + state.finishPerformanceRun() + } catch (e: CancellationException) { + state.cancelPerformanceRun() + throw e + } +} + +private suspend fun runNav3PerformanceAbComparison( + state: Nav3PerformanceState, + backStack: SnapshotStateList, +) { + val originalMode = state.integrationMode + val modes = + if (state.nextAbComparisonRunsDisabledFirst()) { + listOf( + Nav3PerformanceIntegrationMode.DISABLED, + Nav3PerformanceIntegrationMode.FULL_STACK, + ) + } else { + listOf( + Nav3PerformanceIntegrationMode.FULL_STACK, + Nav3PerformanceIntegrationMode.DISABLED, + ) + } + val results = mutableListOf() + + try { + modes.forEach { mode -> + state.updatePerformanceStatus("Warming up ${mode.label}") + state.updateIntegrationMode(mode) + awaitNav3PerformanceFrames() + if ( + !runNav3PerformanceIterations( + iterationCount = PERFORMANCE_WARM_UP_ITERATIONS, + run = Nav3PerformanceRun.TOP_REPLACEMENTS, + state = state, + backStack = backStack, + ) + ) { + return + } + state.startMeasuredIterations() + state.updatePerformanceStatus("Measuring ${mode.label}") + state.beginAbPhase(mode) + val completed = + try { + runNav3PerformanceIterations( + iterationCount = PERFORMANCE_MEASURED_ITERATIONS, + run = Nav3PerformanceRun.TOP_REPLACEMENTS, + state = state, + backStack = backStack, + ) + } finally { + state.finishAbPhase() + } + if (!completed) { + return + } + state.stopCollectingMeasurements() + results += state.performanceSummary(mode.label) + } + } finally { + state.updateIntegrationMode(originalMode) + awaitNav3PerformanceFrames() + } + + state.finishPerformanceRun(results.joinToString(" | ")) +} + +private suspend fun runNav3PerformanceIterations( + iterationCount: Int, + run: Nav3PerformanceRun, + state: Nav3PerformanceState, + backStack: SnapshotStateList, +): Boolean { + repeat(iterationCount) { + if (!state.performanceRunActive) { + return false + } + performNav3PerformanceIteration(run, state, backStack) + awaitNav3PerformanceFrames() + } + return true +} + +private fun performNav3PerformanceIteration( + run: Nav3PerformanceRun, + state: Nav3PerformanceState, + backStack: SnapshotStateList, +) { + when (run) { + Nav3PerformanceRun.UNRELATED_RECOMPOSITIONS -> state.markRecompositionRequest() + Nav3PerformanceRun.TOP_REPLACEMENTS -> { + state.beginNavigationOperation() + backStack.replacePerformanceTop(state.nextGeneration()) + state.markNavigationMutation() + state.markPerformanceDestinationChange() + } + Nav3PerformanceRun.LOWER_ENTRY_MUTATIONS -> { + state.beginNavigationOperation() + backStack.mutatePerformanceLowerEntry(state.nextGeneration()) + state.markNavigationMutation() + } + Nav3PerformanceRun.AB_COMPARISON -> error("A/B comparison runs its own iterations") + } +} + +internal suspend fun awaitNav3PerformanceFrames() { + withFrameNanos {} + withFrameNanos {} +} + +private const val PERFORMANCE_WARM_UP_ITERATIONS = 5 +private const val PERFORMANCE_MEASURED_ITERATIONS = 20 diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt new file mode 100644 index 00000000000..39df2acc1d7 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt @@ -0,0 +1,1295 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.os.Build +import android.os.Trace +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import java.util.Locale +import kotlin.math.ceil +import kotlinx.coroutines.delay + +internal class Nav3PerformanceState( + private val measureRenderLatency: Boolean = false, + private val diagnosticsSurfaceName: String? = null, +) { + var stackDepth by mutableIntStateOf(30) + var recompositionTick by mutableIntStateOf(0) + var displayRevision by mutableIntStateOf(0) + var autoRecompose by mutableStateOf(false) + var autoNavigate by mutableStateOf(false) + var extractorMode by mutableStateOf(Nav3PerformanceExtractorMode.NORMAL) + var argumentMode by mutableStateOf(Nav3PerformanceArgumentMode.FLAT) + var integrationMode by mutableStateOf(Nav3PerformanceIntegrationMode.FULL_STACK) + private set + + var performanceStatus by mutableStateOf("Ready") + private set + + var performanceRunActive by mutableStateOf(false) + private set + + var comparisonResult by mutableStateOf(null) + private set + + var generation = 0 + private set + + var recompositionRequests = 0 + private set + + var navigationMutations = 0 + private set + + var destinationChanges = 0 + private set + + var nameExtractorCalls = 0 + private set + + var argumentsExtractorCalls = 0 + private set + + var nameExtractorNanos = 0L + private set + + var argumentsExtractorNanos = 0L + private set + + var sentryNavEffectAttempts = 0 + private set + + var sentryNavEffectProcessedCalls = 0 + private set + + var capturedEntriesResolved = 0 + private set + + private val sentryNavEffectDurations = Nav3PerformanceDurations() + private val extractorDurations = Nav3PerformanceDurations() + private val mutationToCompositionDurations = Nav3PerformanceDurations() + private val mutationToFirstDrawDurations = Nav3PerformanceDurations() + private var pendingOperation: PendingNav3PerformanceOperation? = null + private var pendingAbPhase: PendingNav3PerformanceTrace? = null + private var pendingMeasuredPhase: PendingNav3PerformanceTrace? = null + private var nextTraceCookie = 0 + private var abComparisonCount = 0 + private var collectMeasurements = true + private var discardNextSentryNavEffectMeasurement = false + private var suppressNextDestinationChange = false + private var pendingProcessedNavigationWork = false + + fun resetCounters() { + finishPendingOperation() + recompositionRequests = 0 + navigationMutations = 0 + destinationChanges = 0 + nameExtractorCalls = 0 + argumentsExtractorCalls = 0 + nameExtractorNanos = 0L + argumentsExtractorNanos = 0L + sentryNavEffectAttempts = 0 + sentryNavEffectProcessedCalls = 0 + capturedEntriesResolved = 0 + sentryNavEffectDurations.clear() + extractorDurations.clear() + mutationToCompositionDurations.clear() + mutationToFirstDrawDurations.clear() + comparisonResult = null + displayRevision++ + } + + fun stopAutomaticWork() { + autoRecompose = false + autoNavigate = false + } + + fun nextGeneration(): Int { + generation++ + return generation + } + + fun markRecompositionRequest() { + beginNavigationOperation(measureFirstDraw = false) + recompositionRequests++ + pendingProcessedNavigationWork = false + recompositionTick++ + if (!performanceRunActive) { + displayRevision++ + } + } + + fun markNavigationMutation() { + navigationMutations++ + pendingProcessedNavigationWork = true + if (!performanceRunActive) { + displayRevision++ + } + } + + fun markDestinationChange() { + if (suppressNextDestinationChange) { + suppressNextDestinationChange = false + return + } + destinationChanges++ + pendingProcessedNavigationWork = true + if (!performanceRunActive) { + displayRevision++ + } + } + + fun markPerformanceDestinationChange() { + destinationChanges++ + pendingProcessedNavigationWork = true + } + + fun suppressNextDestinationChange() { + suppressNextDestinationChange = true + } + + fun updateIntegrationMode(mode: Nav3PerformanceIntegrationMode) { + if (integrationMode != mode) { + discardNextSentryNavEffectMeasurement = true + integrationMode = mode + } + } + + fun beginNavigationOperation(measureFirstDraw: Boolean = true) { + if (!measureRenderLatency) { + return + } + + finishPendingOperation() + val cookie = ++nextTraceCookie + pendingOperation = + PendingNav3PerformanceOperation( + cookie = cookie, + startedAtNanos = System.nanoTime(), + collectMeasurement = collectMeasurements, + measureFirstDraw = measureFirstDraw, + ) + beginAsyncTraceSection(NAVIGATION_TO_COMPOSITION_SECTION, cookie) + if (measureFirstDraw) { + beginAsyncTraceSection(NAVIGATION_TO_FIRST_DRAW_SECTION, cookie) + } + } + + fun recordComposition() { + val operation = pendingOperation ?: return + if (operation.compositionRecorded) { + return + } + + operation.compositionRecorded = true + endAsyncTraceSection(NAVIGATION_TO_COMPOSITION_SECTION, operation.cookie) + if (operation.collectMeasurement) { + mutationToCompositionDurations.add(System.nanoTime() - operation.startedAtNanos) + } + if (!operation.measureFirstDraw) { + pendingOperation = null + } + } + + fun recordFirstDraw() { + val operation = pendingOperation ?: return + if (!operation.measureFirstDraw) { + return + } + endAsyncTraceSection(NAVIGATION_TO_FIRST_DRAW_SECTION, operation.cookie) + if (!operation.compositionRecorded) { + endAsyncTraceSection(NAVIGATION_TO_COMPOSITION_SECTION, operation.cookie) + } + if (operation.collectMeasurement) { + mutationToFirstDrawDurations.add(System.nanoTime() - operation.startedAtNanos) + } + pendingOperation = null + } + + fun recordSentryNavEffect( + durationNanos: Long, + nameExtractorCallsBefore: Int, + argumentsExtractorCallsBefore: Int, + extractorNanosBefore: Long, + processedCall: Boolean, + resolvedEntryCount: Int, + ) { + if (discardNextSentryNavEffectMeasurement) { + discardNextSentryNavEffectMeasurement = false + return + } + if (!collectMeasurements) { + return + } + + val nameExtractorCallCount = nameExtractorCalls - nameExtractorCallsBefore + val argumentsExtractorCallCount = argumentsExtractorCalls - argumentsExtractorCallsBefore + sentryNavEffectAttempts++ + val shouldCountAsProcessed = processedCall && pendingProcessedNavigationWork + if (shouldCountAsProcessed) { + sentryNavEffectProcessedCalls++ + capturedEntriesResolved += resolvedEntryCount + } + pendingProcessedNavigationWork = false + sentryNavEffectDurations.add(durationNanos) + extractorDurations.add(nameExtractorNanos + argumentsExtractorNanos - extractorNanosBefore) + } + + fun startPerformanceRun(status: String) { + finishMeasuredPhase() + performanceRunActive = true + performanceStatus = status + comparisonResult = null + collectMeasurements = false + } + + fun updatePerformanceStatus(status: String) { + performanceStatus = status + } + + fun startMeasuredIterations() { + finishMeasuredPhase() + resetCounters() + collectMeasurements = true + val cookie = ++nextTraceCookie + pendingMeasuredPhase = PendingNav3PerformanceTrace(MEASURED_SECTION, cookie) + beginAsyncTraceSection(MEASURED_SECTION, cookie) + } + + fun stopCollectingMeasurements() { + finishMeasuredPhase() + collectMeasurements = false + } + + fun finishWarmUp() { + resetCounters() + performanceRunActive = true + performanceStatus = "Warm-up complete" + } + + fun finishPerformanceRun(result: String? = null) { + finishMeasuredPhase() + finishAbPhase() + collectMeasurements = true + performanceRunActive = false + performanceStatus = "Complete" + comparisonResult = result + suppressNextDestinationChange = true + pendingProcessedNavigationWork = false + displayRevision++ + emitDiagnosticsSummary() + } + + fun cancelPerformanceRun(status: String = "Ready") { + finishMeasuredPhase() + finishAbPhase() + collectMeasurements = true + performanceRunActive = false + performanceStatus = status + pendingProcessedNavigationWork = false + finishPendingOperation() + } + + fun performanceSummary(label: String): String = + "$label: effect ${sentryNavEffectDurations.compactSummary()}, " + + "first draw ${mutationToFirstDrawDurations.compactSummary()}" + + fun diagnosticsSummary(currentRoute: String, backStack: String): String = buildString { + appendLine("status=$performanceStatus") + appendLine("route=$currentRoute") + appendLine("tracked_stack=$backStack") + appendLine("recomposition_requests=$recompositionRequests") + appendLine("navigation_mutations=$navigationMutations") + appendLine("destination_changes=$destinationChanges") + appendLine("sentry_nav_effect_attempts=$sentryNavEffectAttempts") + appendLine("processed_calls=$sentryNavEffectProcessedCalls") + appendLine("captured_entries_resolved=$capturedEntriesResolved") + appendLine("name_extractor_calls=$nameExtractorCalls") + appendLine("arguments_extractor_calls=$argumentsExtractorCalls") + appendLine("name_extractor_avg=${nameExtractorAverageMicros()}") + appendLine("arguments_extractor_avg=${argumentsExtractorAverageMicros()}") + appendLine("sentry_nav_effect_duration=${sentryNavEffectDurationSummary()}") + appendLine("extractor_duration=${extractorDurationSummary()}") + appendLine("non_extractor_estimate=${nonExtractorDurationSummary()}") + appendLine("mutation_to_composition=${mutationToCompositionSummary()}") + appendLine("mutation_to_first_draw=${mutationToFirstDrawSummary()}") + appendLine("first_draws_over_8_3_ms=${firstDrawsOver8Millis()}") + appendLine("first_draws_over_16_7_ms=${firstDrawsOver16Millis()}") + comparisonResult?.let { append("ab_result=$it") } + } + + private fun emitDiagnosticsSummary() { + val surface = diagnosticsSurfaceName ?: return + Log.i(NAV_PERF_TAG, "NAV_PERF_DIAGNOSTICS_START surface=$surface") + diagnosticsSummary(currentRoute = "", backStack = "") + .trimEnd() + .lineSequence() + .forEach { line -> Log.i(NAV_PERF_TAG, line) } + Log.i(NAV_PERF_TAG, "NAV_PERF_DIAGNOSTICS_END surface=$surface") + } + + fun publishDiagnostics(currentRoute: String, backStack: String) { + val surface = diagnosticsSurfaceName ?: return + Log.i(NAV_PERF_TAG, "NAV_PERF_DIAGNOSTICS_START surface=$surface") + diagnosticsSummary(currentRoute = currentRoute, backStack = backStack) + .trimEnd() + .lineSequence() + .forEach { line -> Log.i(NAV_PERF_TAG, line) } + Log.i(NAV_PERF_TAG, "NAV_PERF_DIAGNOSTICS_END surface=$surface") + } + + fun nextAbComparisonRunsDisabledFirst(): Boolean = abComparisonCount++ % 2 == 0 + + fun beginAbPhase(mode: Nav3PerformanceIntegrationMode) { + finishAbPhase() + val sectionName = + when (mode) { + Nav3PerformanceIntegrationMode.DISABLED -> AB_DISABLED_SECTION + Nav3PerformanceIntegrationMode.FULL_STACK -> AB_ENABLED_SECTION + else -> error("Unsupported A/B integration mode: $mode") + } + val cookie = ++nextTraceCookie + pendingAbPhase = PendingNav3PerformanceTrace(sectionName, cookie) + beginAsyncTraceSection(sectionName, cookie) + } + + fun finishAbPhase() { + pendingAbPhase?.let { phase -> endAsyncTraceSection(phase.sectionName, phase.cookie) } + pendingAbPhase = null + } + + private fun finishMeasuredPhase() { + pendingMeasuredPhase?.let { phase -> endAsyncTraceSection(phase.sectionName, phase.cookie) } + pendingMeasuredPhase = null + } + + fun sentryNavEffectDurationSummary(): String = sentryNavEffectDurations.summary() + + fun extractorDurationSummary(): String = extractorDurations.summary() + + fun nonExtractorDurationSummary(): String = + sentryNavEffectDurations.minus(extractorDurations).summary() + + fun mutationToCompositionSummary(): String = mutationToCompositionDurations.summary() + + fun mutationToFirstDrawSummary(): String = mutationToFirstDrawDurations.summary() + + fun firstDrawsOver8Millis(): Int = mutationToFirstDrawDurations.countOver(8_300_000L) + + fun firstDrawsOver16Millis(): Int = mutationToFirstDrawDurations.countOver(16_700_000L) + + private fun finishPendingOperation() { + val operation = pendingOperation ?: return + if (!operation.compositionRecorded) { + endAsyncTraceSection(NAVIGATION_TO_COMPOSITION_SECTION, operation.cookie) + } + if (operation.measureFirstDraw) { + endAsyncTraceSection(NAVIGATION_TO_FIRST_DRAW_SECTION, operation.cookie) + } + pendingOperation = null + } + + fun recordNameExtraction(sectionName: String, block: () -> String): String { + val startedAt = System.nanoTime() + Trace.beginSection(sectionName) + try { + return block() + } finally { + Trace.endSection() + if (collectMeasurements && !discardNextSentryNavEffectMeasurement) { + nameExtractorCalls++ + nameExtractorNanos += System.nanoTime() - startedAt + } + } + } + + fun recordArgumentExtraction( + sectionName: String, + block: () -> Map, + ): Map { + val startedAt = System.nanoTime() + Trace.beginSection(sectionName) + try { + return block() + } finally { + Trace.endSection() + if (collectMeasurements && !discardNextSentryNavEffectMeasurement) { + argumentsExtractorCalls++ + argumentsExtractorNanos += System.nanoTime() - startedAt + } + } + } +} + +private data class PendingNav3PerformanceOperation( + val cookie: Int, + val startedAtNanos: Long, + val collectMeasurement: Boolean, + val measureFirstDraw: Boolean, + var compositionRecorded: Boolean = false, +) + +private data class PendingNav3PerformanceTrace(val sectionName: String, val cookie: Int) + +internal class Nav3PerformanceDurations { + private val values = mutableListOf() + + val size: Int + get() = values.size + + fun add(durationNanos: Long) { + values += durationNanos.coerceAtLeast(0L) + } + + fun clear() { + values.clear() + } + + fun countOver(thresholdNanos: Long): Int = values.count { it > thresholdNanos } + + fun percentile(percentile: Int): Long { + require(percentile in 0..100) + if (values.isEmpty()) { + return 0L + } + val sortedValues = values.sorted() + val index = (ceil(percentile / 100.0 * sortedValues.size).toInt() - 1).coerceAtLeast(0) + return sortedValues[index] + } + + fun summary(): String = + if (values.isEmpty()) { + "No samples" + } else { + "n=$size, p50=${formatNanos(percentile(50))}, p90=${formatNanos(percentile(90))}, " + + "max=${formatNanos(values.max())}" + } + + fun compactSummary(): String = + if (values.isEmpty()) "disabled" else "p50=${formatNanos(percentile(50))}" + + fun minus(other: Nav3PerformanceDurations): Nav3PerformanceDurations = + Nav3PerformanceDurations().also { result -> + values.forEachIndexed { index, value -> + result.add(value - other.values.getOrElse(index) { 0L }) + } + } +} + +internal enum class Nav3PerformanceExtractorMode(val label: String) { + NORMAL("Normal"), + HEAVY("Heavy"), +} + +internal enum class Nav3PerformanceArgumentMode(val label: String) { + EMPTY("Empty"), + FLAT("Flat"), + NESTED("Nested"), + LARGE("Large"), +} + +internal enum class Nav3PerformanceIntegrationMode( + val label: String, + val captureBackStack: Boolean, + val includeArguments: Boolean, +) { + DISABLED("Disabled", captureBackStack = false, includeArguments = false), + TOP_ONLY("Top only", captureBackStack = false, includeArguments = true), + FULL_STACK("Full stack", captureBackStack = true, includeArguments = true), + FULL_STACK_WITHOUT_ARGUMENTS( + "No arguments", + captureBackStack = true, + includeArguments = false, + ), +} + +internal enum class Nav3PerformanceRun(val label: String) { + UNRELATED_RECOMPOSITIONS("Run 20 recompositions"), + TOP_REPLACEMENTS("Run 20 top replacements"), + LOWER_ENTRY_MUTATIONS("Run 20 lower mutations"), + AB_COMPARISON("Run A/B comparison"), +} + +internal enum class Nav3PerformancePreset( + val label: String, + val stackDepth: Int, + val maxCapturedBackStackEntries: Int, + val integrationMode: Nav3PerformanceIntegrationMode, + val extractorMode: Nav3PerformanceExtractorMode, + val argumentMode: Nav3PerformanceArgumentMode, +) { + LIGHT( + "Light", + 1, + 1, + Nav3PerformanceIntegrationMode.FULL_STACK, + Nav3PerformanceExtractorMode.NORMAL, + Nav3PerformanceArgumentMode.EMPTY, + ), + NORMAL( + "Normal", + 20, + 20, + Nav3PerformanceIntegrationMode.FULL_STACK, + Nav3PerformanceExtractorMode.NORMAL, + Nav3PerformanceArgumentMode.FLAT, + ), + CAPTURE_1_OF_100( + "Capture 1 of 100", + 100, + 1, + Nav3PerformanceIntegrationMode.FULL_STACK, + Nav3PerformanceExtractorMode.NORMAL, + Nav3PerformanceArgumentMode.FLAT, + ), + CAPTURE_20_OF_100( + "Capture 20 of 100", + 100, + 20, + Nav3PerformanceIntegrationMode.FULL_STACK, + Nav3PerformanceExtractorMode.NORMAL, + Nav3PerformanceArgumentMode.FLAT, + ), + CAPTURE_100_OF_100( + "Capture 100 of 100", + 100, + 100, + Nav3PerformanceIntegrationMode.FULL_STACK, + Nav3PerformanceExtractorMode.NORMAL, + Nav3PerformanceArgumentMode.FLAT, + ), + HEAVY( + "Heavy", + 50, + 50, + Nav3PerformanceIntegrationMode.FULL_STACK, + Nav3PerformanceExtractorMode.NORMAL, + Nav3PerformanceArgumentMode.NESTED, + ), + SUPER_HEAVY( + "Super heavy", + 100, + 100, + Nav3PerformanceIntegrationMode.FULL_STACK, + Nav3PerformanceExtractorMode.HEAVY, + Nav3PerformanceArgumentMode.LARGE, + ), + NO_ARGUMENTS( + "No arguments", + 100, + 100, + Nav3PerformanceIntegrationMode.FULL_STACK_WITHOUT_ARGUMENTS, + Nav3PerformanceExtractorMode.HEAVY, + Nav3PerformanceArgumentMode.LARGE, + ), + TOP_ONLY( + "Top only", + 30, + 30, + Nav3PerformanceIntegrationMode.TOP_ONLY, + Nav3PerformanceExtractorMode.NORMAL, + Nav3PerformanceArgumentMode.FLAT, + ), + DISABLED_CONTROL( + "Disabled control", + 30, + 30, + Nav3PerformanceIntegrationMode.DISABLED, + Nav3PerformanceExtractorMode.NORMAL, + Nav3PerformanceArgumentMode.EMPTY, + ), +} + +internal data class Nav3PerformancePanelControls( + val actualStackEntries: Int, + val maxCapturedBackStackEntries: Int, + val onMaxCapturedBackStackEntriesChange: (Int) -> Unit, + val onApplyPreset: (Nav3PerformancePreset) -> Unit, + val onRunPerformance: (Nav3PerformanceRun) -> Unit, +) + +@Composable +internal fun Nav3PerformancePanel( + title: String, + description: String, + currentRoute: String, + backStack: String, + state: Nav3PerformanceState, + showExtractorControls: Boolean, + onBuildStack: () -> Unit, + onMutateLowerEntry: (() -> Unit)? = null, + onReplaceTop: () -> Unit, + nav3Controls: Nav3PerformancePanelControls? = null, +) { + @Suppress("UNUSED_EXPRESSION") state.displayRevision + + val diagnosticsSummary = + state.diagnosticsSummary(currentRoute = currentRoute, backStack = backStack) + + LaunchedEffect(state.autoRecompose) { + while (state.autoRecompose) { + delay(250) + traceNav3PerformanceSection("Nav3Stress.autoRecompose") { + state.markRecompositionRequest() + } + } + } + + LaunchedEffect(state.autoNavigate) { + while (state.autoNavigate) { + delay(500) + traceNav3PerformanceSection("Nav3Stress.autoNavigate") { onReplaceTop() } + } + } + + Column( + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .testTag(navPerformanceTag("root")), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + CollapsiblePerfInfoRow( + label = "Performance status", + value = state.performanceStatus, + collapsedValue = state.performanceStatus, + tag = navPerformanceTag("performance_status"), + defaultExpanded = state.performanceRunActive, + ) + if (!state.performanceRunActive) { + CollapsiblePerfInfoRow( + label = "Diagnostics summary", + value = diagnosticsSummary, + collapsedValue = diagnosticsSummary.lineSequence().firstOrNull() ?: diagnosticsSummary, + tag = navPerformanceTag("diagnostics_summary_top"), + ) + } + + PerfCard(title = "Current State", tag = navPerformanceTag("current_state_card")) { + PerfInfoRow("Current route", currentRoute, tag = navPerformanceTag("current_route")) + PerfInfoRow("Tracked stack", backStack, tag = navPerformanceTag("tracked_stack")) + nav3Controls?.let { controls -> + PerfInfoRow( + "Actual stack entries", + controls.actualStackEntries.toString(), + tag = navPerformanceTag("actual_stack_entries"), + ) + PerfInfoRow( + "Requested stack depth", + state.stackDepth.toString(), + tag = navPerformanceTag("requested_stack_depth"), + ) + PerfInfoRow( + "Integration mode", + state.integrationMode.label, + tag = navPerformanceTag("integration_mode"), + ) + PerfInfoRow( + "Capture enabled", + if (state.integrationMode.captureBackStack) "Yes" else "No", + tag = navPerformanceTag("capture_enabled"), + ) + PerfInfoRow( + "Capture limit", + controls.maxCapturedBackStackEntries.toString(), + tag = navPerformanceTag("capture_limit"), + ) + PerfInfoRow( + "Effective captured entries", + if (state.integrationMode.captureBackStack) { + minOf(controls.actualStackEntries, controls.maxCapturedBackStackEntries).toString() + } else { + "0" + }, + tag = navPerformanceTag("effective_captured_entries"), + ) + } + } + + nav3Controls?.let { controls -> + PerfCard(title = "Scenarios", tag = navPerformanceTag("scenarios_card")) { + Nav3PerformancePreset.entries.chunked(2).forEach { presets -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + presets.forEach { preset -> + OutlinedButton( + enabled = !state.performanceRunActive, + onClick = { controls.onApplyPreset(preset) }, + modifier = + Modifier.weight(1f) + .testTag(navPerformanceTag("preset_${preset.name.lowercase()}")), + ) { + Text(preset.label) + } + } + if (presets.size == 1) { + Spacer(Modifier.weight(1f)) + } + } + } + } + + PerfCard(title = "Nav3 Integration", tag = navPerformanceTag("integration_card")) { + PerfModeRow( + label = "Mode", + selectedLabel = state.integrationMode.label, + enabled = !state.performanceRunActive, + options = + Nav3PerformanceIntegrationMode.entries.map { mode -> + mode.label to { state.updateIntegrationMode(mode) } + }, + ) + PerfStepper( + label = "Capture limit", + value = controls.maxCapturedBackStackEntries, + enabled = !state.performanceRunActive, + onDecrement = { + controls.onMaxCapturedBackStackEntriesChange( + (controls.maxCapturedBackStackEntries - 1).coerceAtLeast(0) + ) + }, + onIncrement = { + controls.onMaxCapturedBackStackEntriesChange( + (controls.maxCapturedBackStackEntries + 1).coerceAtMost(100) + ) + }, + ) + } + } + + PerfCard(title = "Stress Controls", tag = navPerformanceTag("stress_controls_card")) { + PerfStepper( + label = "Stack depth", + value = state.stackDepth, + enabled = !state.performanceRunActive, + onDecrement = { state.stackDepth = (state.stackDepth - 1).coerceAtLeast(1) }, + onIncrement = { state.stackDepth = (state.stackDepth + 1).coerceAtMost(100) }, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + enabled = !state.performanceRunActive, + onClick = onBuildStack, + modifier = Modifier.weight(1f), + ) { + Text("Build Stack") + } + Button( + enabled = !state.performanceRunActive, + onClick = onReplaceTop, + modifier = Modifier.weight(1f), + ) { + Text("Replace Top") + } + } + + onMutateLowerEntry?.let { mutateLowerEntry -> + Button( + enabled = !state.performanceRunActive, + onClick = mutateLowerEntry, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Mutate Lower Entry") + } + } + + Button( + enabled = !state.performanceRunActive, + onClick = { state.markRecompositionRequest() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Force Unrelated Recomposition") + } + + if (nav3Controls == null) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PerfToggleButton( + selected = state.autoRecompose, + label = if (state.autoRecompose) "Stop Recompose" else "Auto Recompose", + onClick = { state.autoRecompose = !state.autoRecompose }, + modifier = Modifier.weight(1f), + ) + PerfToggleButton( + selected = state.autoNavigate, + label = if (state.autoNavigate) "Stop Navigate" else "Auto Navigate", + onClick = { state.autoNavigate = !state.autoNavigate }, + modifier = Modifier.weight(1f), + ) + } + } + } + + nav3Controls?.let { controls -> + PerfCard(title = "Fixed Runs", tag = navPerformanceTag("fixed_runs_card")) { + Nav3PerformanceRun.entries.forEach { run -> + Button( + enabled = !state.performanceRunActive, + onClick = { controls.onRunPerformance(run) }, + modifier = + Modifier.fillMaxWidth().testTag(navPerformanceTag("run_${run.name.lowercase()}")), + ) { + Text(run.label) + } + } + state.comparisonResult?.let { result -> + PerfInfoRow("A/B result", result, tag = navPerformanceTag("ab_result")) + } + } + } + + if (showExtractorControls) { + PerfCard(title = "Extractor Inputs") { + PerfModeRow( + label = "Extractor cost", + selectedLabel = state.extractorMode.label, + enabled = !state.performanceRunActive, + options = + Nav3PerformanceExtractorMode.entries.map { mode -> + mode.label to { state.extractorMode = mode } + }, + ) + PerfModeRow( + label = "Argument shape", + selectedLabel = state.argumentMode.label, + enabled = !state.performanceRunActive, + options = + Nav3PerformanceArgumentMode.entries.map { mode -> + mode.label to { state.argumentMode = mode } + }, + ) + } + } + + PerfCard(title = "Counters", tag = navPerformanceTag("counters_card")) { + PerfInfoRow( + "Recomposition requests", + state.recompositionRequests.toString(), + tag = navPerformanceTag("recomposition_requests"), + ) + PerfInfoRow( + "Navigation mutations", + state.navigationMutations.toString(), + tag = navPerformanceTag("navigation_mutations"), + ) + PerfInfoRow( + "Destination changes", + state.destinationChanges.toString(), + tag = navPerformanceTag("destination_changes"), + ) + if (showExtractorControls) { + if (state.performanceRunActive) { + Text("Metrics are published when the fixed run completes.") + } else { + PerfInfoRow( + "SentryNavEffect attempts", + state.sentryNavEffectAttempts.toString(), + tag = navPerformanceTag("sentry_nav_effect_attempts"), + ) + PerfInfoRow( + "Calls with extractor work", + state.sentryNavEffectProcessedCalls.toString(), + tag = navPerformanceTag("calls_with_extractor_work"), + ) + PerfInfoRow( + "Captured entries resolved", + state.capturedEntriesResolved.toString(), + tag = navPerformanceTag("captured_entries_resolved"), + ) + PerfInfoRow( + "nameExtractor calls", + state.nameExtractorCalls.toString(), + tag = navPerformanceTag("name_extractor_calls"), + ) + PerfInfoRow( + "argumentsExtractor calls", + state.argumentsExtractorCalls.toString(), + tag = navPerformanceTag("arguments_extractor_calls"), + ) + PerfInfoRow( + "nameExtractor avg", + state.nameExtractorAverageMicros(), + tag = navPerformanceTag("name_extractor_avg"), + ) + PerfInfoRow( + "argumentsExtractor avg", + state.argumentsExtractorAverageMicros(), + tag = navPerformanceTag("arguments_extractor_avg"), + ) + PerfInfoRow( + "SentryNavEffect duration", + state.sentryNavEffectDurationSummary(), + tag = navPerformanceTag("sentry_nav_effect_duration"), + ) + PerfInfoRow( + "Extractor duration", + state.extractorDurationSummary(), + tag = navPerformanceTag("extractor_duration"), + ) + PerfInfoRow( + "Non-extractor estimate", + state.nonExtractorDurationSummary(), + tag = navPerformanceTag("non_extractor_estimate"), + ) + PerfInfoRow( + "Mutation to composition", + state.mutationToCompositionSummary(), + tag = navPerformanceTag("mutation_to_composition"), + ) + PerfInfoRow( + "Mutation to first draw", + state.mutationToFirstDrawSummary(), + tag = navPerformanceTag("mutation_to_first_draw"), + ) + PerfInfoRow( + "First draws over 8.3 ms", + state.firstDrawsOver8Millis().toString(), + tag = navPerformanceTag("first_draws_over_8_3_ms"), + ) + PerfInfoRow( + "First draws over 16.7 ms", + state.firstDrawsOver16Millis().toString(), + tag = navPerformanceTag("first_draws_over_16_7_ms"), + ) + } + } + Button( + enabled = !state.performanceRunActive, + onClick = { state.resetCounters() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Reset Counters") + } + Button( + enabled = !state.performanceRunActive, + onClick = { state.publishDiagnostics(currentRoute = currentRoute, backStack = backStack) }, + modifier = Modifier.fillMaxWidth().testTag(navPerformanceTag("publish_diagnostics")), + ) { + Text("Publish diagnostics") + } + } + } +} + +@Composable +private fun PerfCard( + title: String, + tag: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth().then(if (tag != null) Modifier.testTag(tag) else Modifier), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + content() + } + } +} + +@Composable +private fun PerfInfoRow(label: String, value: String, tag: String? = null) { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth().then(if (tag != null) Modifier.testTag(tag) else Modifier), + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + fontWeight = FontWeight.Bold, + modifier = + Modifier.weight(1f).then(if (tag != null) Modifier.testTag("${tag}_label") else Modifier), + ) + Spacer(Modifier.size(12.dp)) + Text( + value, + modifier = + Modifier.weight(1f).then(if (tag != null) Modifier.testTag("${tag}_value") else Modifier), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CollapsiblePerfInfoRow( + label: String, + value: String, + collapsedValue: String, + tag: String? = null, + defaultExpanded: Boolean = false, +) { + var expanded by remember(label, value) { mutableStateOf(defaultExpanded) } + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth().then(if (tag != null) Modifier.testTag(tag) else Modifier), + onClick = { expanded = !expanded }, + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + label, + fontWeight = FontWeight.Bold, + modifier = + Modifier.weight(1f) + .then(if (tag != null) Modifier.testTag("${tag}_label") else Modifier), + ) + TextButton(onClick = { expanded = !expanded }, contentPadding = PaddingValues(0.dp)) { + Text(if (expanded) "Collapse" else "Expand") + } + } + Text( + if (expanded) value else collapsedValue, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier.fillMaxWidth() + .then(if (tag != null) Modifier.testTag("${tag}_value") else Modifier), + ) + } + } +} + +private fun navPerformanceTag(name: String): String = "navigation_perf_$name" + +@Composable +private fun PerfStepper( + label: String, + value: Int, + enabled: Boolean = true, + onDecrement: () -> Unit, + onIncrement: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, fontWeight = FontWeight.Bold) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + modifier = Modifier.size(44.dp), + contentPadding = PaddingValues(0.dp), + enabled = enabled, + onClick = onDecrement, + ) { + Text("-", style = MaterialTheme.typography.titleLarge) + } + Text( + value.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + OutlinedButton( + modifier = Modifier.size(44.dp), + contentPadding = PaddingValues(0.dp), + enabled = enabled, + onClick = onIncrement, + ) { + Text("+", style = MaterialTheme.typography.titleLarge) + } + } + } +} + +@Composable +private fun PerfToggleButton( + selected: Boolean, + label: String, + enabled: Boolean = true, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (selected) { + Button(enabled = enabled, onClick = onClick, modifier = modifier) { Text(label) } + } else { + OutlinedButton(enabled = enabled, onClick = onClick, modifier = modifier) { Text(label) } + } +} + +@Composable +private fun PerfModeRow( + label: String, + selectedLabel: String, + enabled: Boolean = true, + options: List Unit>>, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("$label: $selectedLabel", fontWeight = FontWeight.Bold) + options.chunked(2).forEach { optionRow -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + optionRow.forEach { (optionLabel, onClick) -> + PerfToggleButton( + selected = optionLabel == selectedLabel, + label = optionLabel, + enabled = enabled, + onClick = onClick, + modifier = Modifier.weight(1f), + ) + } + if (optionRow.size == 1) { + Spacer(Modifier.weight(1f)) + } + } + } + } +} + +internal fun traceNav3PerformanceSection(sectionName: String, block: () -> Unit) { + Trace.beginSection(sectionName) + try { + block() + } finally { + Trace.endSection() + } +} + +internal fun nav3PerformanceArguments( + mode: Nav3PerformanceArgumentMode, + index: Int, + generation: Int, +): Map = + when (mode) { + Nav3PerformanceArgumentMode.EMPTY -> emptyMap() + Nav3PerformanceArgumentMode.FLAT -> + mapOf("index" to index, "generation" to generation, "label" to "route-$index") + Nav3PerformanceArgumentMode.NESTED -> + mapOf( + "route" to + mapOf( + "index" to index, + "generation" to generation, + "source" to "performance", + ), + "tags" to listOf("nav", "stress", "route-$index"), + ) + Nav3PerformanceArgumentMode.LARGE -> + (0 until 20).associate { valueIndex -> + "key_$valueIndex" to + mapOf( + "index" to index, + "generation" to generation, + "value" to "payload-$index-$generation-$valueIndex", + "tags" to listOf("a", "b", "c", valueIndex.toString()), + ) + } + } + +@Volatile private var nav3PerformanceExtractorBlackhole = 0 + +internal fun consumeNav3PerformanceExtractorWork( + mode: Nav3PerformanceExtractorMode, + seed: Int, +) { + if (mode == Nav3PerformanceExtractorMode.NORMAL) { + return + } + + var checksum = seed + repeat(2_000) { index -> checksum = (checksum * 31) xor index } + nav3PerformanceExtractorBlackhole = checksum +} + +internal fun nav3PerformanceBackStackPreview(entries: List): String { + if (entries.size <= 8) { + return entries.joinToString(" -> ") + } + + return "${entries.size} entries: " + + entries.take(3).joinToString(" -> ") + + " -> ... -> " + + entries.takeLast(3).joinToString(" -> ") +} + +private fun Nav3PerformanceState.nameExtractorAverageMicros(): String = + averageMicros(nameExtractorNanos, nameExtractorCalls) + +private fun Nav3PerformanceState.argumentsExtractorAverageMicros(): String = + averageMicros(argumentsExtractorNanos, argumentsExtractorCalls) + +private fun averageMicros(totalNanos: Long, count: Int): String = + if (count == 0) "0 us" else "${totalNanos / count / 1_000} us" + +private fun formatNanos(durationNanos: Long): String = + if (durationNanos < 1_000_000L) { + "${durationNanos / 1_000} us" + } else { + String.format(Locale.US, "%.2f ms", durationNanos / 1_000_000.0) + } + +private fun beginAsyncTraceSection(sectionName: String, cookie: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Trace.beginAsyncSection(sectionName, cookie) + } +} + +private fun endAsyncTraceSection(sectionName: String, cookie: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Trace.endAsyncSection(sectionName, cookie) + } +} + +private const val NAVIGATION_TO_COMPOSITION_SECTION = "Nav3Stress.navigationToComposition" +private const val NAVIGATION_TO_FIRST_DRAW_SECTION = "Nav3Stress.navigationToFirstDraw" +private const val AB_DISABLED_SECTION = "Nav3Stress.ab.disabled" +private const val AB_ENABLED_SECTION = "Nav3Stress.ab.enabled" +private const val MEASURED_SECTION = "Nav3Stress.measured" +private const val NAV_PERF_TAG = "NavPerformance" 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 new file mode 100644 index 00000000000..48c0c171ad3 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt @@ -0,0 +1,730 @@ +package io.sentry.samples.android.navigation.nav3 + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +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.navigation.common.RouteNames +import io.sentry.samples.android.navigation.common.RouteSpec +import io.sentry.samples.android.navigation.common.RouteSpecs +import io.sentry.samples.android.navigation.common.RouteWorkApi +import io.sentry.samples.android.navigation.common.RouteWorkOption +import io.sentry.samples.android.navigation.common.displayArguments +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import retrofit2.HttpException + +@ExperimentalComposeUiApi +@Composable +internal fun TracedNav3Route( + route: Nav3Route, + scenario: Nav3Scenario, + content: @Composable BoxScope.() -> Unit, +) { + tagCurrentNav3Scenario(scenario) + SentryTraced( + tag = "Nav3 /${route.routeName}", + enableUserInteractionTracing = false, + content = content, + ) +} + +private fun tagCurrentNav3Scenario(scenario: Nav3Scenario) { + Sentry.getSpan()?.setTag("sample_nav3_scenario", scenario.label) + tagCurrentNavigationSampleScenario(scenario.label) +} + +@Composable +internal fun Nav3RouteWorkEffect( + route: Nav3Route, + routeWorkOptions: Set, +) { + val currentOptions = rememberUpdatedState(routeWorkOptions) + + if (RouteWorkOption.MANUAL_CHILD_SPAN in currentOptions.value) { + // Keep this synchronous to verify that Nav3 route transactions are bound before destination + // composition runs, not merely before destination effects are launched. + runManualNav3RouteActivationSpan(route) + } + + LaunchedEffect(route) { + runNav3RouteWork( + route = route, + options = currentOptions.value, + ) + } +} + +private suspend fun runNav3RouteWork( + route: Nav3Route, + options: Set, +) { + RouteWorkOption.entries.forEach { option -> + if (option !in options || option == RouteWorkOption.MANUAL_CHILD_SPAN) { + return@forEach + } + + tagNav3SampleAction(option.tagName, route) + when (option) { + RouteWorkOption.HTTP_REQUEST -> { + try { + RouteWorkApi.runRequest() + } catch (e: IOException) { + Sentry.captureException(e) + } catch (e: HttpException) { + Sentry.captureException(e) + } finally { + withContext(Dispatchers.IO) { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) } + } + } + RouteWorkOption.MANUAL_CHILD_SPAN -> Unit + } + } +} + +internal fun captureSampleException(navName: String) { + Sentry.captureException(RuntimeException("$navName sample capture exception button")) + Thread { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }.start() +} + +@Composable +internal fun SentryControls( + onCaptureException: () -> Unit, + onCrashApp: () -> Unit, +) { + Surface(shadowElevation = 8.dp) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Nav3SentryButton( + label = "Capture Exception", + onClick = onCaptureException, + modifier = Modifier.weight(1f), + ) + Nav3SentryButton( + label = "Crash App", + onClick = onCrashApp, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +internal fun Nav3SentryButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionLabel: String = label, + testTag: String = nav3ButtonTag(label), +) { + Button( + onClick = onClick, + modifier = modifier.sentryTag(nav3InteractionTag(interactionLabel)).testTag(testTag), + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.colorAccentSoft), + contentColor = Color.White, + ), + ) { + Text(label) + } +} + +internal fun crashSampleApp(navName: String): Nothing { + val crash = RuntimeException("Fatal $navName sample crash app button") + Sentry.captureException(crash) + Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) + throw crash +} + +private fun runManualNav3RouteActivationSpan(route: Nav3Route) { + val span = + Sentry.getSpan() + ?.startChild( + "test.navigation.route_activation", + "Nav3 /${route.routeName} route activation", + ) + span?.setData("sample.route_activation", true) + span?.finish() +} + +private fun tagNav3SampleAction(action: String, route: Nav3Route) { + Sentry.setTag("sample_action", "nav3_$action") + Sentry.setTag("sample_nav3_route", route.routeName) +} + +@Composable +internal fun RouteScaffold( + routeSpec: RouteSpec, + cardContent: (@Composable ColumnScope.() -> Unit)? = null, + footerContent: (@Composable ColumnScope.() -> Unit)? = null, + content: (@Composable ColumnScope.() -> Unit)? = null, + testTagPrefix: String = nav3RouteTag(routeSpec.routeName), +) { + Column( + modifier = + Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(16.dp) + .testTag("${testTagPrefix}_screen") + ) { + Column( + modifier = + Modifier.weight(1f) + .verticalScroll(rememberScrollState()) + .testTag("${testTagPrefix}_content"), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + routeSpec.title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag("${testTagPrefix}_title"), + ) + routeSpec.description?.let { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag("${testTagPrefix}_description"), + ) + } + if (cardContent != null) { + Card( + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth().testTag("${testTagPrefix}_card"), + ) { + Column( + modifier = Modifier.padding(16.dp).testTag("${testTagPrefix}_card_content"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + cardContent() + } + } + } + content?.invoke(this) + } + + if (footerContent != null) { + Spacer(Modifier.size(12.dp)) + Column( + modifier = Modifier.testTag("${testTagPrefix}_footer"), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + footerContent() + } + } + } +} + +@Composable +internal fun RouteButton( + label: String, + onClick: () -> Unit, + testTag: String = nav3ButtonTag(label), +) { + Button( + onClick = onClick, + modifier = Modifier.fillMaxWidth().sentryTag(nav3InteractionTag(label)).testTag(testTag), + ) { + Text(label) + } +} + +@Composable +internal fun RouteInfo(label: String, value: String, testTag: String = nav3InfoTag(label)) { + Row( + modifier = + Modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(8.dp)) + .padding(12.dp) + .testTag(testTag), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, fontWeight = FontWeight.Bold) + Spacer(Modifier.size(12.dp)) + Text(value, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +internal fun SingleStackRoute(backStack: SnapshotStateList) { + RouteScaffold( + routeSpec = RouteSpecs.home, + testTagPrefix = nav3RouteTag("single_stack"), + cardContent = { + RouteButton( + "Browse Products", + onClick = { backStack.add(Nav3Route.ProductList) }, + testTag = nav3TestTag("single_stack_browse_products"), + ) + }, + ) +} + +@Composable +internal fun CustomRoute( + mode: Nav3CustomTransactionMode, + onModeSelected: (Nav3CustomTransactionMode) -> Unit, + isAsyncBrowseProductsRunning: Boolean, + onBrowseProducts: () -> Unit, +) { + val helperText = + when (mode) { + Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION -> + "This mode starts a manual transaction from the button tap, waits for async work, and " + + "then pushes Product List." + Nav3CustomTransactionMode.LINGERING -> + "The lingering transaction stays active until you leave the Custom tab." + else -> null + } + + RouteScaffold( + routeSpec = Nav3Route.Custom.routeSpec(), + testTagPrefix = nav3RouteTag("custom"), + cardContent = { + Nav3CustomTransactionModeSelector(selected = mode, onSelected = onModeSelected) + Text( + mode.description, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(nav3TestTag("custom_mode_description")), + ) + RouteButton( + label = + if (mode == Nav3CustomTransactionMode.ASYNC_FROM_USER_ACTION) { + if (isAsyncBrowseProductsRunning) { + "Starting async custom transaction..." + } else { + "Browse Products" + } + } else { + "Browse Products" + }, + onClick = onBrowseProducts, + testTag = nav3TestTag("custom_browse_products"), + ) + }, + content = { + helperText?.let { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(nav3TestTag("custom_helper_text")), + ) + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Nav3CustomTransactionModeSelector( + selected: Nav3CustomTransactionMode, + onSelected: (Nav3CustomTransactionMode) -> Unit, +) { + val sentryPink = colorResource(R.color.colorAccent) + + Text( + "Mode", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.testTag(nav3TestTag("custom_mode_label")), + ) + SingleChoiceSegmentedButtonRow( + modifier = + Modifier.fillMaxWidth() + .testTag(nav3TestTag("custom_mode_selector")) + .testTag(nav3CustomModeSelectionTag(selected)) + ) { + Nav3CustomTransactionMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + modifier = + Modifier.weight(1f) + .defaultMinSize(minHeight = 72.dp) + .testTag(nav3TestTag("custom_mode_${mode.name.lowercase()}")), + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = Nav3CustomTransactionMode.entries.size, + ), + onClick = { onSelected(mode) }, + selected = selected == mode, + colors = + SegmentedButtonDefaults.colors( + activeContainerColor = sentryPink, + activeContentColor = Color.White, + ), + icon = {}, + label = { + Text( + text = mode.label, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2, + ) + }, + ) + } + } +} + +@Composable +internal fun LandingRoute() { + LaunchedEffect(Unit) { cancelCurrentActivityUiLoadTransaction() } + RouteScaffold(routeSpec = RouteSpecs.landing, testTagPrefix = nav3RouteTag("landing")) +} + +@Composable +internal fun DeepLinkRoute(backStack: SnapshotStateList) { + RouteScaffold( + routeSpec = Nav3Route.DeepLink.routeSpec(), + testTagPrefix = nav3RouteTag("deep_link"), + cardContent = { + RouteButton( + "Go to deep link destination", + onClick = { backStack.openSyntheticProductDeepLink() }, + testTag = nav3TestTag("deep_link_open_destination"), + ) + }, + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun ProductListRoute(backStack: SnapshotStateList) { + var showProductItems by rememberSaveable { mutableStateOf(false) } + + RouteScaffold( + routeSpec = RouteSpecs.productList, + testTagPrefix = nav3RouteTag("product_list"), + cardContent = { + SentryTraced( + tag = "product_list_actions", + modifier = Modifier.fillMaxWidth().testTag(nav3TestTag("product_list_actions")), + enableUserInteractionTracing = false, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + RouteButton( + "Open Product 42", + onClick = { + backStack.add( + Nav3Route.ProductDetail( + productId = "42", + source = "product-list", + campaign = "summer-sale", + ) + ) + }, + testTag = nav3TestTag("product_list_open_product_42"), + ) + RouteButton( + "Open Product 7", + onClick = { + backStack.add(Nav3Route.ProductDetail(productId = "7", source = "product-list")) + }, + testTag = nav3TestTag("product_list_open_product_7"), + ) + } + } + }, + content = { + if (showProductItems) { + SentryTraced( + tag = "product_list_items", + modifier = Modifier.fillMaxWidth().testTag(nav3TestTag("product_list_items")), + enableUserInteractionTracing = false, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + repeat(PRODUCT_LIST_ITEM_COUNT) { index -> Nav3ProductListItem(index + 1) } + } + } + } + }, + footerContent = { + RouteButton( + label = if (showProductItems) "Hide Product Items" else "Show Product Items", + onClick = { showProductItems = !showProductItems }, + testTag = nav3TestTag("product_list_toggle_items"), + ) + }, + ) +} + +@Composable +internal fun ProductDetailRoute( + route: Nav3Route.ProductDetail, + backStack: SnapshotStateList, +) { + LaunchedEffect(route.productId, route.source, route.campaign) { + recordSimulatedBackgroundSpan(RouteNames.PRODUCT_DETAIL) + } + + RouteScaffold( + routeSpec = RouteSpecs.productDetail, + testTagPrefix = nav3RouteTag("product_detail"), + cardContent = { + 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"), + ) + RouteButton( + "Go to Checkout", + onClick = { backStack.add(Nav3Route.Checkout(route.productId)) }, + testTag = nav3TestTag("product_detail_go_to_checkout"), + ) + }, + footerContent = { + RouteButton( + "Emit a span", + onClick = { emitSampleNavigationSpan(route.routeName) }, + testTag = nav3TestTag("product_detail_emit_span"), + ) + }, + ) +} + +@Composable +internal fun CheckoutRoute( + route: Nav3Route.Checkout, + backStack: SnapshotStateList, +) { + RouteScaffold( + routeSpec = RouteSpecs.checkout, + testTagPrefix = nav3RouteTag("checkout"), + cardContent = { + RouteSpecs.checkout.displayArguments(route.arguments).forEach { (label, value) -> + RouteInfo(label, value) + } + RouteButton( + "Complete Order", + onClick = { backStack.add(Nav3Route.Confirmation(orderId = "order-${route.productId}")) }, + testTag = nav3TestTag("checkout_complete_order"), + ) + }, + ) +} + +@Composable +internal fun ConfirmationRoute( + route: Nav3Route.Confirmation, + backStack: SnapshotStateList, + rootRoute: Nav3Route, +) { + RouteScaffold( + routeSpec = RouteSpecs.confirmation, + testTagPrefix = nav3RouteTag("confirmation"), + cardContent = { + RouteSpecs.confirmation.displayArguments(route.arguments).forEach { (label, value) -> + RouteInfo(label, value) + } + RouteButton( + "Reset Backstack", + onClick = { backStack.resetTo(rootRoute) }, + testTag = nav3TestTag("confirmation_reset_backstack"), + ) + }, + ) +} + +@Composable +internal fun PromoDialogRoute( + route: Nav3Route.PromoDialog, + backStack: SnapshotStateList, + onCaptureException: () -> Unit, + onCrashApp: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().testTag(nav3TestTag("promo_dialog_card")), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Column( + modifier = Modifier.padding(24.dp).testTag(nav3TestTag("promo_dialog_content")), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val routeSpec = RouteSpecs.promoDialog + Text(routeSpec.title, style = MaterialTheme.typography.headlineSmall) + routeSpec.description?.let { Text(it) } + routeSpec.displayArguments(route.arguments).forEach { (label, value) -> + Text("$label=$value") + } + Nav3SentryButton( + label = "Capture Exception", + onClick = onCaptureException, + modifier = Modifier.fillMaxWidth(), + interactionLabel = "Promo Dialog Exception", + testTag = nav3TestTag("promo_dialog_capture_exception"), + ) + Nav3SentryButton( + label = "Crash App", + onClick = onCrashApp, + modifier = Modifier.fillMaxWidth(), + interactionLabel = "Promo Dialog Crash App", + testTag = nav3TestTag("promo_dialog_crash_app"), + ) + Button( + onClick = { backStack.removeLastOrNull() }, + modifier = + Modifier.fillMaxWidth() + .sentryTag(nav3InteractionTag("Promo Dialog Dismiss")) + .testTag(nav3TestTag("promo_dialog_dismiss")), + ) { + Text("Dismiss") + } + } + } +} + +@Composable +internal fun ShareSheetRoute( + route: Nav3Route.ShareSheet, + backStack: SnapshotStateList, + onCaptureException: () -> Unit, + onCrashApp: () -> Unit, +) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp) + .testTag(nav3TestTag("share_sheet_content")), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val routeSpec = RouteSpecs.shareSheet + Text(routeSpec.title, style = MaterialTheme.typography.headlineSmall) + routeSpec.description?.let { Text(it) } + routeSpec.displayArguments(route.arguments).forEach { (label, value) -> Text("$label=$value") } + Nav3SentryButton( + label = "Capture Exception", + onClick = onCaptureException, + modifier = Modifier.fillMaxWidth(), + interactionLabel = "Share Sheet Exception", + testTag = nav3TestTag("share_sheet_capture_exception"), + ) + Nav3SentryButton( + label = "Crash App", + onClick = onCrashApp, + modifier = Modifier.fillMaxWidth(), + interactionLabel = "Share Sheet Crash App", + testTag = nav3TestTag("share_sheet_crash_app"), + ) + Button( + onClick = { backStack.removeLastOrNull() }, + modifier = + Modifier.fillMaxWidth() + .sentryTag(nav3InteractionTag("Share Sheet Done")) + .testTag(nav3TestTag("share_sheet_done")), + ) { + Text("Done") + } + Spacer(Modifier.size(12.dp)) + } +} + +@Composable +internal fun FutureRoute(routeName: String, scenario: String) { + RouteScaffold( + routeSpec = + RouteSpecs.get(routeName) + .copy( + title = "$routeName: WIP", + description = + "Reserved for a future milestone when SentryNavEffect supports $scenario navigation " + + "state.", + ), + testTagPrefix = nav3RouteTag(routeName), + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun Nav3ProductListItem(index: Int) { + SentryTraced( + tag = "product_list_item_$index", + modifier = Modifier.fillMaxWidth().testTag(nav3TestTag("product_list_item_$index")), + enableUserInteractionTracing = false, + ) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth(), + ) { + androidx.compose.foundation.layout.Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Product #$index", fontWeight = FontWeight.Bold) + Text("SKU-$index") + } + } + } +} + +private const val PRODUCT_LIST_ITEM_COUNT = 20 + +private fun nav3RouteTag(routeName: String): String = + nav3TestTag("route_${routeName.lowercase().replace(' ', '_').replace('/', '_')}") + +private fun nav3ButtonTag(label: String): String = + nav3TestTag( + "button_${label.lowercase().replace(' ', '_').replace('/', '_').replace('.', '_').replace('-', '_')}" + ) + +private fun nav3InfoTag(label: String): String = + nav3TestTag( + "info_${label.lowercase().replace(' ', '_').replace('/', '_').replace('.', '_').replace('-', '_')}" + ) + +private fun nav3CustomModeSelectionTag(mode: Nav3CustomTransactionMode): String = + nav3TestTag("custom_mode_selected_${mode.name.lowercase()}") 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 new file mode 100644 index 00000000000..fa5ccdd83a5 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt @@ -0,0 +1,293 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList +import io.sentry.samples.android.navigation.common.NavArgs +import io.sentry.samples.android.navigation.common.RouteNames +import io.sentry.samples.android.navigation.common.RouteSpec +import io.sentry.samples.android.navigation.common.RouteSpecs +import io.sentry.samples.android.navigation.common.displayRoute + +internal fun Nav3Route.displayRoute(): String = routeSpec().displayRoute(arguments) + +internal fun Nav3Route.routeSpec(): RouteSpec = + when (this) { + Nav3Route.Landing -> RouteSpecs.landing + Nav3Route.SingleStack -> RouteSpecs.home + Nav3Route.Custom -> + RouteSpecs.home.copy( + routeName = Nav3Route.Custom.routeName, + title = "Custom Transactions", + description = + "Reuses the single-stack shopping flow while simulating a power user who starts their " + + "own manual transactions.", + ) + Nav3Route.DeepLink -> + RouteSpec( + routeName = Nav3Route.DeepLink.routeName, + title = "Deep Link", + description = + "Simulates opening a deep link that builds a synthetic backstack before landing on a " + + "detail destination.", + ) + Nav3Route.ProductList -> RouteSpecs.productList + is Nav3Route.ProductDetail -> RouteSpecs.productDetail + is Nav3Route.Checkout -> RouteSpecs.checkout + is Nav3Route.Confirmation -> RouteSpecs.confirmation + is Nav3Route.PromoDialog -> RouteSpecs.promoDialog + is Nav3Route.ShareSheet -> RouteSpecs.shareSheet + Nav3Route.Multipane, + Nav3Route.Multistack, + is Nav3Route.Performance -> RouteSpec(routeName = routeName, title = routeName) + } + +@Composable +internal fun rememberSaveableNav3BackStack(initialRoute: Nav3Route): SnapshotStateList { + return rememberSaveable(saver = nav3BackStackSaver()) { mutableStateListOf(initialRoute) } +} + +private fun nav3BackStackSaver() = + listSaver, Bundle>( + save = { stack -> stack.map { route -> route.toSavedState() } }, + restore = { savedRoutes -> + mutableStateListOf().apply { + addAll(savedRoutes.map { savedRoute -> savedRoute.toNav3Route() }) + if (isEmpty()) { + add(Nav3Route.SingleStack) + } + } + }, + ) + +private fun Nav3Route.toSavedState(): Bundle = + Bundle().apply { + when (this@toSavedState) { + Nav3Route.Landing -> putString("type", "landing") + Nav3Route.SingleStack -> putString("type", "single_stack") + Nav3Route.Custom -> putString("type", "custom") + Nav3Route.DeepLink -> putString("type", "deep_link") + Nav3Route.ProductList -> putString("type", "product_list") + is Nav3Route.ProductDetail -> { + putString("type", "product_detail") + putString("product_id", productId) + putString("source", source) + putString("campaign", campaign) + } + is Nav3Route.Checkout -> { + putString("type", "checkout") + putString("product_id", productId) + } + is Nav3Route.Confirmation -> { + putString("type", "confirmation") + putString("order_id", orderId) + } + is Nav3Route.PromoDialog -> { + putString("type", "promo_dialog") + putString("promo_id", promoId) + } + is Nav3Route.ShareSheet -> { + putString("type", "share_sheet") + putString("product_id", productId) + } + Nav3Route.Multipane -> putString("type", "multipane") + Nav3Route.Multistack -> putString("type", "multistack") + is Nav3Route.Performance -> { + putString("type", "performance") + putInt("index", index) + putInt("generation", generation) + } + } + } + +private fun Bundle.toNav3Route(): Nav3Route { + return when (getString("type")) { + "landing" -> Nav3Route.Landing + "single_stack" -> Nav3Route.SingleStack + "custom" -> Nav3Route.Custom + "deep_link" -> Nav3Route.DeepLink + "product_list" -> Nav3Route.ProductList + "product_detail" -> + Nav3Route.ProductDetail( + productId = requireNotNull(getString("product_id")), + source = requireNotNull(getString("source")), + campaign = getString("campaign"), + ) + "checkout" -> Nav3Route.Checkout(productId = requireNotNull(getString("product_id"))) + "confirmation" -> Nav3Route.Confirmation(orderId = requireNotNull(getString("order_id"))) + "promo_dialog" -> Nav3Route.PromoDialog(promoId = requireNotNull(getString("promo_id"))) + "share_sheet" -> Nav3Route.ShareSheet(productId = requireNotNull(getString("product_id"))) + "multipane" -> Nav3Route.Multipane + "multistack" -> Nav3Route.Multistack + "performance" -> + Nav3Route.Performance( + index = getInt("index"), + generation = getInt("generation"), + ) + else -> Nav3Route.SingleStack + } +} + +internal fun SnapshotStateList.resetTo(route: Nav3Route) { + clear() + add(route) +} + +internal fun SnapshotStateList.openScenario(scenario: Nav3Scenario) { + when (scenario) { + Nav3Scenario.LANDING -> resetTo(Nav3Route.Landing) + Nav3Scenario.SINGLE_STACK -> resetTo(Nav3Route.SingleStack) + Nav3Scenario.CUSTOM -> resetTo(Nav3Route.Custom) + Nav3Scenario.DEEP_LINK -> resetTo(Nav3Route.DeepLink) + Nav3Scenario.MULTIPANE -> resetTo(Nav3Route.Multipane) + Nav3Scenario.MULTIPLE_STACKS -> resetTo(Nav3Route.Multistack) + Nav3Scenario.PERFORMANCE -> resetTo(Nav3Route.Performance(index = 0, generation = 0)) + } +} + +internal fun SnapshotStateList.openSyntheticProductDeepLink() { + clear() + add(Nav3Route.SingleStack) + add(Nav3Route.ProductList) + add(Nav3Route.ProductDetail(productId = "42", source = "deep-link", campaign = "email")) +} + +internal fun SnapshotStateList.openPerformanceStack(depth: Int, generation: Int) { + clear() + repeat(depth.coerceAtLeast(1)) { index -> + add(Nav3Route.Performance(index = index, generation = generation)) + } +} + +internal fun SnapshotStateList.mutatePerformanceLowerEntry(generation: Int) { + if (isEmpty()) { + add(Nav3Route.Performance(index = 0, generation = generation)) + return + } + + val index = if (size > 1) 0 else lastIndex + set(index, Nav3Route.Performance(index = index, generation = generation)) +} + +internal fun SnapshotStateList.replacePerformanceTop(generation: Int) { + if (isEmpty()) { + add(Nav3Route.Performance(index = 0, generation = generation)) + return + } + + set(lastIndex, Nav3Route.Performance(index = lastIndex, generation = generation)) +} + +internal sealed interface Nav3Route { + val routeName: String + val arguments: Map + get() = emptyMap() + + val previewName: String + get() = routeName + + val performanceSeed: Int + get() = hashCode() + + data object Landing : Nav3Route { + override val routeName: String = RouteNames.LANDING + } + + data object SingleStack : Nav3Route { + override val routeName: String = RouteNames.HOME + } + + data object Custom : Nav3Route { + override val routeName: String = "Custom" + } + + data object DeepLink : Nav3Route { + override val routeName: String = RouteNames.DEEP_LINK + } + + data object ProductList : Nav3Route { + override val routeName: String = RouteNames.PRODUCT_LIST + } + + data class ProductDetail( + val productId: String, + val source: String, + val campaign: String? = null, + ) : Nav3Route { + override val routeName: String = RouteNames.PRODUCT_DETAIL + override val arguments: Map = + mapOf( + NavArgs.PRODUCT_ID to productId, + NavArgs.SOURCE to source, + NavArgs.CAMPAIGN to campaign, + ) + .filterValues { it != null } + override val previewName: String = "ProductDetail($productId)" + } + + data class Checkout(val productId: String) : Nav3Route { + override val routeName: String = RouteNames.CHECKOUT + override val arguments: Map = mapOf(NavArgs.PRODUCT_ID to productId) + override val previewName: String = "Checkout($productId)" + } + + data class Confirmation(val orderId: String) : Nav3Route { + override val routeName: String = RouteNames.CONFIRMATION + override val arguments: Map = mapOf(NavArgs.ORDER_ID to orderId) + override val previewName: String = "Confirmation($orderId)" + } + + data class PromoDialog(val promoId: String) : Nav3Route { + override val routeName: String = RouteNames.PROMO_DIALOG + override val arguments: Map = mapOf(NavArgs.PROMO_ID to promoId) + override val previewName: String = "PromoDialog($promoId)" + } + + data class ShareSheet(val productId: String) : Nav3Route { + override val routeName: String = RouteNames.SHARE_SHEET + override val arguments: Map = mapOf(NavArgs.PRODUCT_ID to productId) + override val previewName: String = "ShareSheet($productId)" + } + + data object Multipane : Nav3Route { + override val routeName: String = "Multipane" + override val arguments: Map = mapOf("scenario" to "multipane") + } + + data object Multistack : Nav3Route { + override val routeName: String = "Multistack" + override val arguments: Map = mapOf("scenario" to "multistack") + } + + data class Performance(val index: Int, val generation: Int) : Nav3Route { + override val routeName: String = "Performance" + override val arguments: Map = mapOf("index" to index, "generation" to generation) + override val previewName: String = "Performance($index:$generation)" + override val performanceSeed: Int = 31 * index + generation + } +} + +internal enum class Nav3Scenario(val label: String, val showTab: Boolean = true) { + LANDING(RouteNames.LANDING, showTab = false), + SINGLE_STACK("Single Stack"), + DEEP_LINK("Deep Link"), + CUSTOM("Custom"), + MULTIPANE("Multipane"), + MULTIPLE_STACKS("Multistack"), + PERFORMANCE("Performance"), +} + +internal val Nav3Scenario.initialRoute: Nav3Route + get() = + when (this) { + Nav3Scenario.LANDING -> Nav3Route.Landing + Nav3Scenario.SINGLE_STACK -> Nav3Route.SingleStack + Nav3Scenario.CUSTOM -> Nav3Route.Custom + Nav3Scenario.DEEP_LINK -> Nav3Route.DeepLink + Nav3Scenario.MULTIPANE -> Nav3Route.Multipane + Nav3Scenario.MULTIPLE_STACKS -> Nav3Route.Multistack + Nav3Scenario.PERFORMANCE -> Nav3Route.Performance(index = 0, generation = 0) + } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleConfig.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleConfig.kt new file mode 100644 index 00000000000..45eadc367ca --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleConfig.kt @@ -0,0 +1,139 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import io.sentry.samples.android.navigation.common.NavigationSampleConfig +import io.sentry.samples.android.navigation.common.NavigationSampleConfigSnapshot + +internal fun Intent.previousNav3SampleConfigSnapshot( + fallback: NavigationSampleConfigSnapshot +): NavigationSampleConfigSnapshot = + NavigationSampleConfigSnapshot( + enableScreenTracking = + getBooleanExtra(EXTRA_PREVIOUS_ENABLE_SCREEN_TRACKING, fallback.enableScreenTracking), + enableUserInteractionTransactions = + getBooleanExtra( + EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_TRANSACTIONS, + fallback.enableUserInteractionTransactions, + ), + enableUserInteractionBreadcrumbs = + getBooleanExtra( + EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_BREADCRUMBS, + fallback.enableUserInteractionBreadcrumbs, + ), + ) + +internal fun Intent.nav3SampleConfig(): NavigationSampleConfig = + NavigationSampleConfig( + enableNavigationTransactions = getBooleanExtra(EXTRA_ENABLE_NAVIGATION_TRANSACTIONS, true), + enableNavigationBreadcrumbs = getBooleanExtra(EXTRA_ENABLE_NAVIGATION_BREADCRUMBS, true), + enableScreenTracking = getBooleanExtra(EXTRA_ENABLE_SCREEN_TRACKING, true), + enableActivityUiLoadTransaction = + getBooleanExtra(EXTRA_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION, false), + enableUserInteractionTransactions = + getBooleanExtra(EXTRA_ENABLE_USER_INTERACTION_TRANSACTIONS, false), + enableUserInteractionBreadcrumbs = + getBooleanExtra(EXTRA_ENABLE_USER_INTERACTION_BREADCRUMBS, false), + captureBackStack = getBooleanExtra(EXTRA_CAPTURE_BACK_STACK, true), + maxCapturedBackStackEntries = + getIntExtra(EXTRA_MAX_CAPTURED_BACK_STACK_ENTRIES, 10).coerceAtLeast(1), + ) + +internal fun Context.nav3LaunchIntent( + configuration: NavigationSampleConfig, + previousOptions: NavigationSampleConfigSnapshot, +): Intent = + Intent(this, Nav3Activity::class.java) + .putExtra(EXTRA_ENABLE_NAVIGATION_TRANSACTIONS, configuration.enableNavigationTransactions) + .putExtra(EXTRA_ENABLE_NAVIGATION_BREADCRUMBS, configuration.enableNavigationBreadcrumbs) + .putExtra(EXTRA_ENABLE_SCREEN_TRACKING, configuration.enableScreenTracking) + .putExtra( + EXTRA_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION, + configuration.enableActivityUiLoadTransaction, + ) + .putExtra( + EXTRA_ENABLE_USER_INTERACTION_TRANSACTIONS, + configuration.enableUserInteractionTransactions, + ) + .putExtra( + EXTRA_ENABLE_USER_INTERACTION_BREADCRUMBS, + configuration.enableUserInteractionBreadcrumbs, + ) + .putExtra(EXTRA_CAPTURE_BACK_STACK, configuration.captureBackStack) + .putExtra(EXTRA_MAX_CAPTURED_BACK_STACK_ENTRIES, configuration.maxCapturedBackStackEntries) + .putExtra(EXTRA_PREVIOUS_ENABLE_SCREEN_TRACKING, previousOptions.enableScreenTracking) + .putExtra( + EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_TRANSACTIONS, + previousOptions.enableUserInteractionTransactions, + ) + .putExtra( + EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_BREADCRUMBS, + previousOptions.enableUserInteractionBreadcrumbs, + ) + +internal fun Bundle.putNav3SampleConfiguration(configuration: NavigationSampleConfig) { + putBoolean(STATE_ENABLE_NAVIGATION_TRANSACTIONS, configuration.enableNavigationTransactions) + putBoolean(STATE_ENABLE_NAVIGATION_BREADCRUMBS, configuration.enableNavigationBreadcrumbs) + putBoolean(STATE_ENABLE_SCREEN_TRACKING, configuration.enableScreenTracking) + putBoolean( + STATE_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION, + configuration.enableActivityUiLoadTransaction, + ) + putBoolean( + STATE_ENABLE_USER_INTERACTION_TRANSACTIONS, + configuration.enableUserInteractionTransactions, + ) + putBoolean( + STATE_ENABLE_USER_INTERACTION_BREADCRUMBS, + configuration.enableUserInteractionBreadcrumbs, + ) + putBoolean(STATE_CAPTURE_BACK_STACK, configuration.captureBackStack) + putInt(STATE_MAX_CAPTURED_BACK_STACK_ENTRIES, configuration.maxCapturedBackStackEntries) +} + +internal fun Bundle.nav3SampleConfiguration(): NavigationSampleConfig = + NavigationSampleConfig( + enableNavigationTransactions = getBoolean(STATE_ENABLE_NAVIGATION_TRANSACTIONS, true), + enableNavigationBreadcrumbs = getBoolean(STATE_ENABLE_NAVIGATION_BREADCRUMBS, true), + enableScreenTracking = getBoolean(STATE_ENABLE_SCREEN_TRACKING, true), + enableActivityUiLoadTransaction = getBoolean(STATE_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION, false), + enableUserInteractionTransactions = + getBoolean(STATE_ENABLE_USER_INTERACTION_TRANSACTIONS, false), + enableUserInteractionBreadcrumbs = getBoolean(STATE_ENABLE_USER_INTERACTION_BREADCRUMBS, false), + captureBackStack = getBoolean(STATE_CAPTURE_BACK_STACK, true), + maxCapturedBackStackEntries = + getInt(STATE_MAX_CAPTURED_BACK_STACK_ENTRIES, 10).coerceAtLeast(1), + ) + +private const val EXTRA_PREFIX = "io.sentry.samples.android.navigation.nav3" +private const val EXTRA_ENABLE_NAVIGATION_TRANSACTIONS = + "$EXTRA_PREFIX.enable_navigation_transactions" +private const val EXTRA_ENABLE_NAVIGATION_BREADCRUMBS = + "$EXTRA_PREFIX.enable_navigation_breadcrumbs" +private const val EXTRA_ENABLE_SCREEN_TRACKING = "$EXTRA_PREFIX.enable_screen_tracking" +private const val EXTRA_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION = + "$EXTRA_PREFIX.enable_activity_ui_load_transaction" +private const val EXTRA_ENABLE_USER_INTERACTION_TRANSACTIONS = + "$EXTRA_PREFIX.enable_user_interaction_transactions" +private const val EXTRA_ENABLE_USER_INTERACTION_BREADCRUMBS = + "$EXTRA_PREFIX.enable_user_interaction_breadcrumbs" +private const val EXTRA_CAPTURE_BACK_STACK = "$EXTRA_PREFIX.capture_back_stack" +private const val EXTRA_MAX_CAPTURED_BACK_STACK_ENTRIES = + "$EXTRA_PREFIX.max_captured_back_stack_entries" +private const val EXTRA_PREVIOUS_ENABLE_SCREEN_TRACKING = + "$EXTRA_PREFIX.previous_enable_screen_tracking" +private const val EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_TRANSACTIONS = + "$EXTRA_PREFIX.previous_enable_user_interaction_transactions" +private const val EXTRA_PREVIOUS_ENABLE_USER_INTERACTION_BREADCRUMBS = + "$EXTRA_PREFIX.previous_enable_user_interaction_breadcrumbs" + +private const val STATE_ENABLE_NAVIGATION_TRANSACTIONS = "enable_navigation_transactions" +private const val STATE_ENABLE_NAVIGATION_BREADCRUMBS = "enable_navigation_breadcrumbs" +private const val STATE_ENABLE_SCREEN_TRACKING = "enable_screen_tracking" +private const val STATE_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION = "enable_activity_ui_load_transaction" +private const val STATE_ENABLE_USER_INTERACTION_TRANSACTIONS = + "enable_user_interaction_transactions" +private const val STATE_ENABLE_USER_INTERACTION_BREADCRUMBS = "enable_user_interaction_breadcrumbs" +private const val STATE_CAPTURE_BACK_STACK = "capture_back_stack" +private const val STATE_MAX_CAPTURED_BACK_STACK_ENTRIES = "max_captured_back_stack_entries" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTheme.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTheme.kt new file mode 100644 index 00000000000..c24dbe707c0 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTheme.kt @@ -0,0 +1,60 @@ +package io.sentry.samples.android.navigation.nav3 + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import io.sentry.samples.android.R + +@Composable +internal fun Nav3SampleTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val primaryColor = Color(ContextCompat.getColor(context, R.color.colorPrimary)) + val accentColor = Color(ContextCompat.getColor(context, R.color.colorAccent)) + val darkBackground = Color(0xFF333333) + val darkSurface = Color(0xFF333333) + val darkSurfaceVariant = Color(0xFF454545) + val darkSurfaceContainer = Color(0xFF66666A) + val darkOnBackground = Color(0xFFF2ECF7) + val darkOnSurface = Color(0xFFF2ECF7) + val darkOnSurfaceVariant = Color(0xFFD0CAD6) + val lightBackground = Color(0xFFF6F1F8) + val lightSurface = Color(0xFFF6F1F8) + val lightSurfaceVariant = Color(0xFFE4DEEA) + val lightOnBackground = Color(0xFF241F29) + val lightOnSurface = Color(0xFF241F29) + val lightOnSurfaceVariant = Color(0xFF5F5868) + val colorScheme = + if (isSystemInDarkTheme()) { + darkColorScheme( + primary = primaryColor, + secondary = accentColor, + tertiary = primaryColor, + background = darkBackground, + surface = darkSurface, + surfaceVariant = darkSurfaceVariant, + surfaceContainer = darkSurfaceContainer, + onBackground = darkOnBackground, + onSurface = darkOnSurface, + onSurfaceVariant = darkOnSurfaceVariant, + ) + } else { + lightColorScheme( + primary = primaryColor, + secondary = accentColor, + tertiary = primaryColor, + background = lightBackground, + surface = lightSurface, + surfaceVariant = lightSurfaceVariant, + onBackground = lightOnBackground, + onSurface = lightOnSurface, + onSurfaceVariant = lightOnSurfaceVariant, + ) + } + + MaterialTheme(colorScheme = colorScheme, content = content) +} 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 new file mode 100644 index 00000000000..802757a06d6 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt @@ -0,0 +1,77 @@ +package io.sentry.samples.android.navigation.nav3 + +import io.sentry.Sentry +import io.sentry.SpanStatus +import io.sentry.protocol.SentryTransaction +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine + +internal fun tagCurrentNavigationSampleScenario(scenarioLabel: String) { + Sentry.getSpan()?.setTag(NAVIGATION_SAMPLE_SCENARIO_TAG, scenarioLabel) + Sentry.configureScope { scope -> + scope.withTransaction { transaction -> + transaction?.setTag(NAVIGATION_SAMPLE_SCENARIO_TAG, scenarioLabel) + } + } +} + +internal fun SentryTransaction.navigationSampleScenarioLabel(): String = + getTag(NAVIGATION_SAMPLE_SCENARIO_TAG) + ?: getTag(NAV2_SCENARIO_TAG) + ?: UNKNOWN_NAVIGATION_SCENARIO_LABEL + +internal fun cancelCurrentActivityUiLoadTransaction() { + Sentry.configureScope { scope -> + scope.withTransaction { transaction -> + if (transaction?.operation == ACTIVITY_UI_LOAD_OP) { + transaction.forceFinish(SpanStatus.CANCELLED, false, null) + scope.clearTransaction() + } + } + } +} + +internal suspend fun recordSimulatedBackgroundSpan(routeName: String) { + val parentSpan = Sentry.getSpan() + suspendCancellableCoroutine { continuation -> + val worker = Thread { + val span = + parentSpan?.startChild( + "test.navigation.background_work", + "Nav3 /$routeName background work", + ) + span?.setData("sample.background_work", true) + try { + Thread.sleep(BACKGROUND_WORK_MILLIS) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + span?.finish() + if (continuation.isActive) { + continuation.resume(Unit) + } + } + } + continuation.invokeOnCancellation { worker.interrupt() } + worker.start() + } +} + +internal fun emitSampleNavigationSpan(routeName: String) { + val span = + Sentry.getSpan() + ?.startChild( + "test.navigation.emit_span", + "Nav3 /$routeName emit a span", + ) + span?.setData("sample.emit_span", true) + span?.finish() +} + +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 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 new file mode 100644 index 00000000000..db945d45198 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt @@ -0,0 +1,49 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.sentry.samples.android.navigation.common.NavigationSampleConfig +import io.sentry.samples.android.navigation.common.NavigationSetupScreen +import io.sentry.samples.android.navigation.common.applyToCurrentOptions +import io.sentry.samples.android.navigation.common.currentNavigationSampleConfigSnapshot + +/** + * Activity for configuring the developer's experience in the [Nav3Activity], matching the Nav2 + * setup flow while exposing Nav3-only back stack capture options. + */ +class Nav3SetupActivity : AppCompatActivity() { + + private var configuration by mutableStateOf(NavigationSampleConfig()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + configuration = savedInstanceState?.nav3SampleConfiguration() ?: configuration + setContent { + Nav3SampleTheme { + NavigationSetupScreen( + navName = "Nav3", + navVersion = "3", + configuration = configuration, + showBackStackControls = true, + onConfigurationChanged = { updatedConfiguration -> + configuration = updatedConfiguration + }, + onLaunch = { + val previousOptions = currentNavigationSampleConfigSnapshot() + configuration.applyToCurrentOptions() + startActivity(nav3LaunchIntent(configuration, previousOptions)) + }, + ) + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putNav3SampleConfiguration(configuration) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TopBar.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TopBar.kt new file mode 100644 index 00000000000..d2872f3c939 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TopBar.kt @@ -0,0 +1,172 @@ +package io.sentry.samples.android.navigation.nav3 + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.sentry.compose.SentryModifier.sentryTag + +@Composable +internal fun Nav3TopBar( + backStack: List, + selectedScenario: Nav3Scenario, + maxCapturedBackStackEntries: Int, + onTransactionHistoryClick: () -> Unit, + onRouteWorkSettingsClick: () -> Unit, + onScenarioSelected: (Nav3Scenario) -> Unit, +) { + val currentRoute = backStack.lastOrNull() ?: Nav3Route.SingleStack + val currentRouteText = currentRoute.displayRoute() + val capturedBackStackEntries = + backStack.takeLast(maxCapturedBackStackEntries).map { route -> "/${route.previewName}" } + val capturedBackStack = + capturedBackStackEntries + .mapIndexed { index, route -> + if (index == 0 && backStack.size > maxCapturedBackStackEntries) { + "... $route" + } else { + route + } + } + .joinToString(" -> ") + val selectedTabColor = MaterialTheme.colorScheme.primary + + Surface( + color = MaterialTheme.colorScheme.background, + shadowElevation = 4.dp, + modifier = Modifier.testTag(nav3TestTag("top_bar")), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 24.dp, top = 18.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Navigation 3", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f).testTag(nav3TestTag("title")), + ) + IconButton( + onClick = onTransactionHistoryClick, + modifier = + Modifier.sentryTag(nav3InteractionTag("Recent Transactions")) + .testTag(nav3TestTag("top_bar_recent_transactions")), + ) { + Icon( + imageVector = Icons.Filled.AccountTree, + contentDescription = "Recent transactions", + ) + } + IconButton( + onClick = onRouteWorkSettingsClick, + modifier = + Modifier.sentryTag(nav3InteractionTag("Route Work Settings")) + .testTag(nav3TestTag("top_bar_route_work_settings")), + ) { + Icon(imageVector = Icons.Filled.Settings, contentDescription = "Route work settings") + } + } + Column( + modifier = + Modifier.padding(start = 24.dp, end = 24.dp, bottom = 12.dp) + .testTag(nav3TestTag("route_summary")), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "Current route: $currentRouteText", + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier.horizontalScroll(rememberScrollState()).testTag(nav3TestTag("current_route")), + maxLines = 1, + ) + Text( + text = "Nav3 back stack: $capturedBackStack", + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier.horizontalScroll(rememberScrollState()).testTag(nav3TestTag("back_stack")), + maxLines = 1, + ) + } + ScenarioBar( + selectedScenario = selectedScenario, + selectedTabColor = selectedTabColor, + onScenarioSelected = onScenarioSelected, + ) + } + } +} + +@Composable +private fun ScenarioBar( + selectedScenario: Nav3Scenario, + selectedTabColor: Color, + onScenarioSelected: (Nav3Scenario) -> Unit, +) { + val scenarios = Nav3Scenario.entries.filter { scenario -> scenario.showTab } + + Row( + modifier = + Modifier.fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(start = 24.dp, end = 24.dp) + .testTag(nav3TestTag("scenario_bar")) + ) { + scenarios.forEach { scenario -> + val selected = selectedScenario == scenario + Column( + modifier = + Modifier.defaultMinSize(minWidth = 120.dp) + .clickable { onScenarioSelected(scenario) } + .testTag(nav3ScenarioTabTag(scenario)), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = scenario.label, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp), + color = if (selected) selectedTabColor else MaterialTheme.colorScheme.onBackground, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + ) + Box( + modifier = + Modifier.fillMaxWidth() + .height(4.dp) + .background( + if (selected) selectedTabColor else Color.Transparent, + RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp), + ) + ) + } + } + } +} + +internal fun nav3InteractionTag(label: String): String = "Nav3 $label" + +internal fun nav3TestTag(name: String): String = "nav3_$name" + +internal fun nav3ScenarioTabTag(scenario: Nav3Scenario): String = + nav3TestTag("tab_${scenario.name.lowercase()}") 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 new file mode 100644 index 00000000000..38cd6d2039b --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt @@ -0,0 +1,176 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.os.Handler +import android.os.Looper +import androidx.compose.runtime.mutableStateListOf +import io.sentry.Sentry +import io.sentry.protocol.SentrySpan +import io.sentry.protocol.SentryTransaction +import io.sentry.samples.android.SampleBeforeSendTransactionHook + +/** State holder backing the navigation sample transaction history sheet. */ +internal class NavigationTransactionHistory(private val isActive: () -> Boolean) { + + val transactions = mutableStateListOf() + + private val mainHandler = Handler(Looper.getMainLooper()) + private val transactionListener: (SentryTransaction, String?) -> Unit = { transaction, dsn -> + add(transaction, dsn) + } + + fun install() { + val options = Sentry.getCurrentScopes().options + SampleBeforeSendTransactionHook.installIfNeeded(options) + SampleBeforeSendTransactionHook.addListener(transactionListener) + } + + fun uninstall() { + clear() + SampleBeforeSendTransactionHook.removeListener(transactionListener) + } + + fun clear() { + transactions.clear() + } + + private fun add(transaction: SentryTransaction, dsn: String?) { + if (!isActive()) { + return + } + + val trace = transaction.toNavigationTransactionTrace(dsn) + if (Looper.myLooper() == Looper.getMainLooper()) { + transactions.addMostRecent(trace) + } else { + mainHandler.post { + if (isActive()) { + transactions.addMostRecent(trace) + } + } + } + } +} + +internal data class NavigationTransactionTrace( + val name: String, + val operation: String, + val eventId: String, + val traceId: String, + val status: String?, + val tab: String, + val durationMillis: Double, + val sentryUrl: String?, + val spans: List, +) + +internal data class NavigationTraceSpan( + val spanId: String, + val parentSpanId: String?, + val operation: String, + val description: String?, + val startOffsetMillis: Double, + val durationMillis: Double, + val children: List = emptyList(), +) + +private fun SentryTransaction.toNavigationTransactionTrace( + dsn: String? +): NavigationTransactionTrace { + val trace = contexts.trace + val startTimestamp = startTimestamp + val endTimestamp = + timestamp ?: spans.mapNotNull { span -> span.timestamp }.maxOrNull() ?: startTimestamp + val durationMillis = ((endTimestamp - startTimestamp) * 1_000.0).coerceAtLeast(0.0) + val rootSpanId = trace?.spanId?.toString() + val traceId = trace?.traceId?.toString().orEmpty() + val eventId = eventId?.toString().orEmpty() + val rawSpans = spans.map { it.toNavigationTraceSpan(startTimestamp) } + val spanIds = rawSpans.map { it.spanId }.toSet() + val spansByParentId = rawSpans.groupBy { span -> span.parentSpanId } + val topLevelSpans = + rawSpans + .filter { span -> + span.parentSpanId == null || + span.parentSpanId == rootSpanId || + span.parentSpanId !in spanIds + } + .sortedBy { span -> span.startOffsetMillis } + + return NavigationTransactionTrace( + name = transaction ?: "", + operation = trace?.operation ?: "transaction", + eventId = eventId, + traceId = traceId, + status = status?.name, + tab = navigationSampleScenarioLabel(), + durationMillis = durationMillis, + sentryUrl = sentryTransactionUrl(dsn, traceId, rootSpanId, eventId, endTimestamp), + spans = topLevelSpans.withChildren(spansByParentId), + ) +} + +private fun MutableList.addMostRecent( + transaction: NavigationTransactionTrace +) { + add(0, transaction) + while (size > TRANSACTION_HISTORY_LIMIT) { + removeAt(lastIndex) + } +} + +private fun SentrySpan.toNavigationTraceSpan( + transactionStartTimestamp: Double +): NavigationTraceSpan = + NavigationTraceSpan( + spanId = spanId.toString(), + parentSpanId = parentSpanId?.toString(), + operation = op, + description = description, + startOffsetMillis = ((startTimestamp - transactionStartTimestamp) * 1_000.0).coerceAtLeast(0.0), + durationMillis = + (((timestamp ?: startTimestamp) - startTimestamp) * 1_000.0).coerceAtLeast(0.0), + ) + +private fun List.withChildren( + spansByParentId: Map> +): List = map { span -> span.withChildren(spansByParentId) } + +private fun NavigationTraceSpan.withChildren( + spansByParentId: Map> +): NavigationTraceSpan = + copy( + children = + spansByParentId[spanId] + .orEmpty() + .sortedBy { span -> span.startOffsetMillis } + .withChildren(spansByParentId) + ) + +private fun sentryTransactionUrl( + dsn: String?, + traceId: String, + rootSpanId: String?, + eventId: String, + timestampSeconds: Double, +): String? { + val projectId = dsn?.projectIdFromDsn() ?: return null + if (traceId.isEmpty() || rootSpanId.isNullOrEmpty() || eventId.isEmpty()) { + return null + } + return "https://$SENTRY_SAMPLE_ORG_SLUG.sentry.io/explore/traces/trace/$traceId/" + + "?node=span-$rootSpanId" + + "&project=$projectId" + + "&source=traces" + + "&statsPeriod=14d" + + "&targetId=$eventId" + + "×tamp=${timestampSeconds.toLong()}" +} + +private fun String.projectIdFromDsn(): String? = + substringBefore('?').substringBefore('#').trimEnd('/').substringAfterLast('/').takeIf { projectId + -> + projectId.isNotEmpty() && projectId.all { it.isDigit() } + } + +private const val TRANSACTION_HISTORY_LIMIT = 10 +private const val SENTRY_SAMPLE_ORG_SLUG = "sentry-sdks" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt new file mode 100644 index 00000000000..0e0038083ea --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt @@ -0,0 +1,368 @@ +package io.sentry.samples.android.navigation.nav3 + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import java.util.Locale +import kotlin.math.roundToInt + +/** + * Bottom sheet for displaying recent navigation sample transactions generated by the Sentry SDK. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun NavigationTransactionHistorySheet( + sampleName: String, + transactions: List, + showActivityUiLoadTransactionDelayMessage: Boolean, + onDismissRequest: () -> Unit, + onOpenTransaction: (String) -> Unit, + onDumpTransactionUrl: (String) -> Unit, + onCopyTransactionUrl: (String) -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismissRequest) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp) + .testTag(historyTag(sampleName, "sheet")), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "Recent transactions", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + 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.", + style = MaterialTheme.typography.bodyMedium, + ) + Column( + modifier = + Modifier.fillMaxWidth() + .verticalScroll(rememberScrollState()) + .testTag(historyTag(sampleName, "list")), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (transactions.isEmpty()) { + EmptyTransactionHistory(showActivityUiLoadTransactionDelayMessage, sampleName) + } else { + transactions.forEachIndexed { index, transaction -> + TransactionCard( + sampleName = sampleName, + index = index, + transaction = transaction, + onOpenTransaction = onOpenTransaction, + onDumpTransactionUrl = onDumpTransactionUrl, + onCopyTransactionUrl = onCopyTransactionUrl, + ) + } + } + } + } + } +} + +@Composable +private fun EmptyTransactionHistory( + showActivityUiLoadTransactionDelayMessage: Boolean, + sampleName: String, +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth().testTag(historyTag(sampleName, "empty")), + ) { + Text( + if (showActivityUiLoadTransactionDelayMessage) { + ACTIVITY_UI_LOAD_EMPTY_HISTORY_MESSAGE + } else { + EMPTY_HISTORY_MESSAGE + }, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun TransactionCard( + sampleName: String, + index: Int, + transaction: NavigationTransactionTrace, + onOpenTransaction: (String) -> Unit, + onDumpTransactionUrl: (String) -> Unit, + onCopyTransactionUrl: (String) -> Unit, +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth().testTag(historyTag(sampleName, "card_$index")), + ) { + Column( + modifier = Modifier.padding(12.dp).testTag(historyTag(sampleName, "card_${index}_content")), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column( + modifier = Modifier.weight(1f).testTag(historyTag(sampleName, "card_${index}_summary")) + ) { + Text( + transaction.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_name")), + ) + Text( + listOfNotNull(transaction.operation, transaction.status).joinToString(" - "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + 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")), + ) + } + + TransactionWaterfall(transaction, historyTag(sampleName, "card_${index}_waterfall")) + + if (transaction.sentryUrl == null) { + Text( + "Sentry URL unavailable for this DSN.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Row( + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_actions")), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton( + onClick = { onOpenTransaction(transaction.sentryUrl) }, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_open")), + ) { + Text("Open in Sentry") + } + TextButton( + onClick = { onDumpTransactionUrl(transaction.sentryUrl) }, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_dump")), + ) { + Text("Dump URL") + } + TextButton( + onClick = { onCopyTransactionUrl(transaction.sentryUrl) }, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_copy")), + ) { + Text("Copy URL") + } + } + } + } + } +} + +@Composable +private fun TransactionWaterfall(transaction: NavigationTransactionTrace, testTag: String) { + val rows = transaction.spans.flattenTraceRows() + val scrollState = rememberScrollState() + Column(modifier = Modifier.horizontalScroll(scrollState).testTag(testTag)) { + TimelineHeader(transaction.durationMillis) + TraceRow( + label = transaction.name, + operation = transaction.operation, + level = 0, + startOffsetMillis = 0.0, + durationMillis = transaction.durationMillis, + totalDurationMillis = transaction.durationMillis, + color = ROOT_SPAN_COLOR, + showBranch = false, + ) + rows.forEachIndexed { index, row -> + TraceRow( + label = row.span.description ?: row.span.operation, + operation = row.span.operation, + level = row.level, + startOffsetMillis = row.span.startOffsetMillis, + durationMillis = row.span.durationMillis, + totalDurationMillis = transaction.durationMillis, + color = spanBarColor(index, row.span.operation), + showBranch = true, + ) + } + } +} + +@Composable +private fun TimelineHeader(totalDurationMillis: Double) { + Row(modifier = Modifier.width(WATERFALL_WIDTH).height(28.dp)) { + Spacer(Modifier.width(LABEL_WIDTH)) + Row( + modifier = Modifier.width(TIMELINE_WIDTH), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + listOf(0.0, 0.25, 0.5, 0.75, 1.0).forEach { fraction -> + Text( + (totalDurationMillis * fraction).formatMillis(), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun TraceRow( + label: String, + operation: String, + level: Int, + startOffsetMillis: Double, + durationMillis: Double, + totalDurationMillis: Double, + color: Color, + showBranch: Boolean, +) { + Row( + modifier = Modifier.width(WATERFALL_WIDTH).height(34.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.width(LABEL_WIDTH).padding(end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width((level * 16).dp)) + if (showBranch) { + Text("|-", color = MaterialTheme.colorScheme.outline) + Spacer(Modifier.width(4.dp)) + } + Text( + "$operation - $label", + style = MaterialTheme.typography.bodySmall, + fontWeight = if (level == 0) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + BoxWithConstraints(modifier = Modifier.width(TIMELINE_WIDTH).height(24.dp)) { + val safeTotalDuration = totalDurationMillis.coerceAtLeast(1.0) + val startFraction = (startOffsetMillis / safeTotalDuration).coerceIn(0.0, 1.0).toFloat() + val widthFraction = (durationMillis / safeTotalDuration).coerceIn(0.0, 1.0).toFloat() + val barX = maxWidth * startFraction + val barWidth = (maxWidth * widthFraction).coerceAtLeast(3.dp).coerceAtMost(maxWidth - barX) + + TimelineGrid() + Box( + modifier = + Modifier.offset(x = barX) + .width(barWidth) + .height(12.dp) + .align(Alignment.CenterStart) + .clip(RoundedCornerShape(2.dp)) + .background(color) + ) + Text( + durationMillis.formatMillis(), + modifier = Modifier.offset(x = barX + barWidth + 4.dp).align(Alignment.CenterStart), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} + +@Composable +private fun TimelineGrid() { + Row(modifier = Modifier.width(TIMELINE_WIDTH).height(24.dp)) { + repeat(4) { + Box(modifier = Modifier.weight(1f).height(24.dp)) { + Box( + modifier = + Modifier.width(1.dp) + .height(24.dp) + .align(Alignment.CenterEnd) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) + ) + } + } + } +} + +private fun List.flattenTraceRows(level: Int = 1): List = + flatMap { span -> + listOf(TraceRowData(span = span, level = level)) + span.children.flattenTraceRows(level + 1) + } + +private fun spanBarColor(index: Int, operation: String): Color = + when { + operation.startsWith("ui.compose") -> COMPOSE_SPAN_COLOR + operation.startsWith("ui.render") -> RENDER_SPAN_COLOR + operation.startsWith("http") -> HTTP_SPAN_COLOR + else -> CHILD_SPAN_COLORS[index % CHILD_SPAN_COLORS.size] + } + +private fun Double.formatMillis(): String = + when { + this < 10.0 -> String.format(Locale.ROOT, "%.2fms", this) + this < 100.0 -> String.format(Locale.ROOT, "%.1fms", this) + else -> "${roundToInt()}ms" + } + +private data class TraceRowData(val span: NavigationTraceSpan, val level: Int) + +private const val EMPTY_HISTORY_MESSAGE = + "No finished transactions have been emitted yet. Transactions with child work appear here after their work finishes." +private const val ACTIVITY_UI_LOAD_EMPTY_HISTORY_MESSAGE = + "No transactions have been emitted yet. ui.load transactions can take up to 30 seconds to " + + "appear after the last span is produced." +private val ROOT_SPAN_COLOR = Color(0xFFE95F5C) +private val COMPOSE_SPAN_COLOR = Color(0xFF5B3DB6) +private val RENDER_SPAN_COLOR = Color(0xFFBBD233) +private val HTTP_SPAN_COLOR = Color(0xFF2F80ED) +private val CHILD_SPAN_COLORS = + listOf(Color(0xFF7553D6), Color(0xFF9B51E0), Color(0xFF27AE60), Color(0xFFF2C94C)) + +private val LABEL_WIDTH = 260.dp +private val TIMELINE_WIDTH = 420.dp +private val WATERFALL_WIDTH = LABEL_WIDTH + TIMELINE_WIDTH + +private fun historyTag(sampleName: String, suffix: String): String = + "${sampleName.lowercase(Locale.ROOT)}_transaction_history_$suffix" From cf0785f17ae505caaa361f84baeef517deec1b55 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 5 Oct 2026 11:09:20 +0200 Subject: [PATCH 2/8] fix(samples): Harden Nav3 state restoration Fall back to the single-stack route when saved route arguments are missing and remove unused extractor-count bookkeeping from performance measurements.\n\nCo-Authored-By: Codex --- .../android/navigation/nav3/Nav3Activity.kt | 4 ---- .../nav3/Nav3PerformanceControls.kt | 4 ---- .../android/navigation/nav3/Nav3Routes.kt | 21 ++++++++++++------- 3 files changed, 14 insertions(+), 15 deletions(-) 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 9f975bf2f48..6eec1afd252 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 @@ -296,8 +296,6 @@ private fun Nav3SampleApp( controller = customTransactionController, ) - val nameExtractorCallsBefore = performanceState.nameExtractorCalls - val argumentsExtractorCallsBefore = performanceState.argumentsExtractorCalls val extractorNanosBefore = performanceState.nameExtractorNanos + performanceState.argumentsExtractorNanos val startedAtNanos = System.nanoTime() @@ -311,8 +309,6 @@ private fun Nav3SampleApp( if (isPerformanceScenario) { performanceState.recordSentryNavEffect( durationNanos = System.nanoTime() - startedAtNanos, - nameExtractorCallsBefore = nameExtractorCallsBefore, - argumentsExtractorCallsBefore = argumentsExtractorCallsBefore, extractorNanosBefore = extractorNanosBefore, processedCall = true, resolvedEntryCount = diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt index 39df2acc1d7..0fab429e6ae 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt @@ -240,8 +240,6 @@ internal class Nav3PerformanceState( fun recordSentryNavEffect( durationNanos: Long, - nameExtractorCallsBefore: Int, - argumentsExtractorCallsBefore: Int, extractorNanosBefore: Long, processedCall: Boolean, resolvedEntryCount: Int, @@ -254,8 +252,6 @@ internal class Nav3PerformanceState( return } - val nameExtractorCallCount = nameExtractorCalls - nameExtractorCallsBefore - val argumentsExtractorCallCount = argumentsExtractorCalls - argumentsExtractorCallsBefore sentryNavEffectAttempts++ val shouldCountAsProcessed = processedCall && pendingProcessedNavigationWork if (shouldCountAsProcessed) { 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 fa5ccdd83a5..b9972a6cd76 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 @@ -110,16 +110,23 @@ private fun Bundle.toNav3Route(): Nav3Route { "custom" -> Nav3Route.Custom "deep_link" -> Nav3Route.DeepLink "product_list" -> Nav3Route.ProductList - "product_detail" -> + "product_detail" -> { + val productId = getString("product_id") ?: return Nav3Route.SingleStack + val source = getString("source") ?: return Nav3Route.SingleStack Nav3Route.ProductDetail( - productId = requireNotNull(getString("product_id")), - source = requireNotNull(getString("source")), + productId = productId, + source = source, campaign = getString("campaign"), ) - "checkout" -> Nav3Route.Checkout(productId = requireNotNull(getString("product_id"))) - "confirmation" -> Nav3Route.Confirmation(orderId = requireNotNull(getString("order_id"))) - "promo_dialog" -> Nav3Route.PromoDialog(promoId = requireNotNull(getString("promo_id"))) - "share_sheet" -> Nav3Route.ShareSheet(productId = requireNotNull(getString("product_id"))) + } + "checkout" -> + Nav3Route.Checkout(productId = getString("product_id") ?: return Nav3Route.SingleStack) + "confirmation" -> + Nav3Route.Confirmation(orderId = getString("order_id") ?: return Nav3Route.SingleStack) + "promo_dialog" -> + Nav3Route.PromoDialog(promoId = getString("promo_id") ?: return Nav3Route.SingleStack) + "share_sheet" -> + Nav3Route.ShareSheet(productId = getString("product_id") ?: return Nav3Route.SingleStack) "multipane" -> Nav3Route.Multipane "multistack" -> Nav3Route.Multistack "performance" -> From 76818d93dd9b50c697716b70828bf26bed7ee368 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 5 Oct 2026 11:29:28 +0200 Subject: [PATCH 3/8] fix(samples): Measure Nav3 effect after application Record the performance sample from a SideEffect so the SentryNavEffect DisposableEffect and its extractors have completed before timing is captured.\n\nCo-Authored-By: Codex --- .../android/navigation/nav3/Nav3Activity.kt | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) 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 6eec1afd252..8ee751c43e6 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 @@ -307,17 +307,19 @@ private fun Nav3SampleApp( ) if (isPerformanceScenario) { - performanceState.recordSentryNavEffect( - durationNanos = System.nanoTime() - startedAtNanos, - extractorNanosBefore = extractorNanosBefore, - processedCall = true, - resolvedEntryCount = - if (integrationMode.captureBackStack) { - minOf(sentryBackStack.size, maxCapturedBackStackEntries) - } else { - 0 - }, - ) + SideEffect { + performanceState.recordSentryNavEffect( + durationNanos = System.nanoTime() - startedAtNanos, + extractorNanosBefore = extractorNanosBefore, + processedCall = true, + resolvedEntryCount = + if (integrationMode.captureBackStack) { + minOf(sentryBackStack.size, maxCapturedBackStackEntries) + } else { + 0 + }, + ) + } } } From 0bb37f168c630b7e69f8863ccaa07276e28aca90 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 5 Oct 2026 11:50:20 +0200 Subject: [PATCH 4/8] fix(nav3): Remove unverifiable Nav3 timing metrics --- .../android/navigation/nav3/Nav3Activity.kt | 26 +++++--------- .../nav3/Nav3PerformanceControls.kt | 36 +------------------ 2 files changed, 10 insertions(+), 52 deletions(-) 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 8ee751c43e6..b2a8d05720a 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 @@ -296,10 +296,6 @@ private fun Nav3SampleApp( controller = customTransactionController, ) - val extractorNanosBefore = - performanceState.nameExtractorNanos + performanceState.argumentsExtractorNanos - val startedAtNanos = System.nanoTime() - SentryNavEffect( backStack = sentryBackStack, backStackEntryMapper = backStackEntryMapper, @@ -307,19 +303,15 @@ private fun Nav3SampleApp( ) if (isPerformanceScenario) { - SideEffect { - performanceState.recordSentryNavEffect( - durationNanos = System.nanoTime() - startedAtNanos, - extractorNanosBefore = extractorNanosBefore, - processedCall = true, - resolvedEntryCount = - if (integrationMode.captureBackStack) { - minOf(sentryBackStack.size, maxCapturedBackStackEntries) - } else { - 0 - }, - ) - } + performanceState.recordSentryNavEffect( + processedCall = true, + resolvedEntryCount = + if (integrationMode.captureBackStack) { + minOf(sentryBackStack.size, maxCapturedBackStackEntries) + } else { + 0 + }, + ) } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt index 0fab429e6ae..5126f7f5521 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt @@ -97,8 +97,6 @@ internal class Nav3PerformanceState( var capturedEntriesResolved = 0 private set - private val sentryNavEffectDurations = Nav3PerformanceDurations() - private val extractorDurations = Nav3PerformanceDurations() private val mutationToCompositionDurations = Nav3PerformanceDurations() private val mutationToFirstDrawDurations = Nav3PerformanceDurations() private var pendingOperation: PendingNav3PerformanceOperation? = null @@ -123,8 +121,6 @@ internal class Nav3PerformanceState( sentryNavEffectAttempts = 0 sentryNavEffectProcessedCalls = 0 capturedEntriesResolved = 0 - sentryNavEffectDurations.clear() - extractorDurations.clear() mutationToCompositionDurations.clear() mutationToFirstDrawDurations.clear() comparisonResult = null @@ -239,8 +235,6 @@ internal class Nav3PerformanceState( } fun recordSentryNavEffect( - durationNanos: Long, - extractorNanosBefore: Long, processedCall: Boolean, resolvedEntryCount: Int, ) { @@ -259,8 +253,6 @@ internal class Nav3PerformanceState( capturedEntriesResolved += resolvedEntryCount } pendingProcessedNavigationWork = false - sentryNavEffectDurations.add(durationNanos) - extractorDurations.add(nameExtractorNanos + argumentsExtractorNanos - extractorNanosBefore) } fun startPerformanceRun(status: String) { @@ -319,8 +311,7 @@ internal class Nav3PerformanceState( } fun performanceSummary(label: String): String = - "$label: effect ${sentryNavEffectDurations.compactSummary()}, " + - "first draw ${mutationToFirstDrawDurations.compactSummary()}" + "$label: first draw ${mutationToFirstDrawDurations.compactSummary()}" fun diagnosticsSummary(currentRoute: String, backStack: String): String = buildString { appendLine("status=$performanceStatus") @@ -336,9 +327,6 @@ internal class Nav3PerformanceState( appendLine("arguments_extractor_calls=$argumentsExtractorCalls") appendLine("name_extractor_avg=${nameExtractorAverageMicros()}") appendLine("arguments_extractor_avg=${argumentsExtractorAverageMicros()}") - appendLine("sentry_nav_effect_duration=${sentryNavEffectDurationSummary()}") - appendLine("extractor_duration=${extractorDurationSummary()}") - appendLine("non_extractor_estimate=${nonExtractorDurationSummary()}") appendLine("mutation_to_composition=${mutationToCompositionSummary()}") appendLine("mutation_to_first_draw=${mutationToFirstDrawSummary()}") appendLine("first_draws_over_8_3_ms=${firstDrawsOver8Millis()}") @@ -391,13 +379,6 @@ internal class Nav3PerformanceState( pendingMeasuredPhase = null } - fun sentryNavEffectDurationSummary(): String = sentryNavEffectDurations.summary() - - fun extractorDurationSummary(): String = extractorDurations.summary() - - fun nonExtractorDurationSummary(): String = - sentryNavEffectDurations.minus(extractorDurations).summary() - fun mutationToCompositionSummary(): String = mutationToCompositionDurations.summary() fun mutationToFirstDrawSummary(): String = mutationToFirstDrawDurations.summary() @@ -954,21 +935,6 @@ internal fun Nav3PerformancePanel( state.argumentsExtractorAverageMicros(), tag = navPerformanceTag("arguments_extractor_avg"), ) - PerfInfoRow( - "SentryNavEffect duration", - state.sentryNavEffectDurationSummary(), - tag = navPerformanceTag("sentry_nav_effect_duration"), - ) - PerfInfoRow( - "Extractor duration", - state.extractorDurationSummary(), - tag = navPerformanceTag("extractor_duration"), - ) - PerfInfoRow( - "Non-extractor estimate", - state.nonExtractorDurationSummary(), - tag = navPerformanceTag("non_extractor_estimate"), - ) PerfInfoRow( "Mutation to composition", state.mutationToCompositionSummary(), From 0b0196dd824725444444262b3d0bfc79198712b0 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 5 Oct 2026 13:00:17 +0200 Subject: [PATCH 5/8] fix(samples): Correct Nav3 measurement state Restore the idle state after warm-up and keep integration-mode recompositions out of extractor and processed-call counters until real navigation work begins. Refs GH-6222 Co-Authored-By: Codex --- .../nav3/Nav3PerformanceControls.kt | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt index 5126f7f5521..c3ef2d68d07 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt @@ -106,6 +106,7 @@ internal class Nav3PerformanceState( private var abComparisonCount = 0 private var collectMeasurements = true private var discardNextSentryNavEffectMeasurement = false + private var suppressExtractorMeasurementsUntilNavigation = false private var suppressNextDestinationChange = false private var pendingProcessedNavigationWork = false @@ -121,6 +122,7 @@ internal class Nav3PerformanceState( sentryNavEffectAttempts = 0 sentryNavEffectProcessedCalls = 0 capturedEntriesResolved = 0 + pendingProcessedNavigationWork = false mutationToCompositionDurations.clear() mutationToFirstDrawDurations.clear() comparisonResult = null @@ -149,7 +151,7 @@ internal class Nav3PerformanceState( fun markNavigationMutation() { navigationMutations++ - pendingProcessedNavigationWork = true + markNavigationWorkPending() if (!performanceRunActive) { displayRevision++ } @@ -161,7 +163,7 @@ internal class Nav3PerformanceState( return } destinationChanges++ - pendingProcessedNavigationWork = true + markNavigationWorkPending() if (!performanceRunActive) { displayRevision++ } @@ -169,7 +171,7 @@ internal class Nav3PerformanceState( fun markPerformanceDestinationChange() { destinationChanges++ - pendingProcessedNavigationWork = true + markNavigationWorkPending() } fun suppressNextDestinationChange() { @@ -179,6 +181,8 @@ internal class Nav3PerformanceState( fun updateIntegrationMode(mode: Nav3PerformanceIntegrationMode) { if (integrationMode != mode) { discardNextSentryNavEffectMeasurement = true + suppressExtractorMeasurementsUntilNavigation = true + pendingProcessedNavigationWork = false integrationMode = mode } } @@ -240,6 +244,7 @@ internal class Nav3PerformanceState( ) { if (discardNextSentryNavEffectMeasurement) { discardNextSentryNavEffectMeasurement = false + pendingProcessedNavigationWork = false return } if (!collectMeasurements) { @@ -283,8 +288,7 @@ internal class Nav3PerformanceState( fun finishWarmUp() { resetCounters() - performanceRunActive = true - performanceStatus = "Warm-up complete" + cancelPerformanceRun(status = "Warm-up complete") } fun finishPerformanceRun(result: String? = null) { @@ -405,7 +409,7 @@ internal class Nav3PerformanceState( return block() } finally { Trace.endSection() - if (collectMeasurements && !discardNextSentryNavEffectMeasurement) { + if (collectMeasurements && !suppressExtractorMeasurementsUntilNavigation) { nameExtractorCalls++ nameExtractorNanos += System.nanoTime() - startedAt } @@ -422,12 +426,17 @@ internal class Nav3PerformanceState( return block() } finally { Trace.endSection() - if (collectMeasurements && !discardNextSentryNavEffectMeasurement) { + if (collectMeasurements && !suppressExtractorMeasurementsUntilNavigation) { argumentsExtractorCalls++ argumentsExtractorNanos += System.nanoTime() - startedAt } } } + + private fun markNavigationWorkPending() { + suppressExtractorMeasurementsUntilNavigation = false + pendingProcessedNavigationWork = true + } } private data class PendingNav3PerformanceOperation( From b8eecf3e5319b494b856324c063f7464dc7a6f6c Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 6 Oct 2026 11:45:44 +0200 Subject: [PATCH 6/8] fix(samples): Correct Nav3 sample review findings Record performance counters after successful composition, finish stale custom transactions on return to the home route, and clear A/B measurement guards after restoring integration mode. Refs JAVA-274 Co-Authored-By: Codex --- .../android/navigation/nav3/Nav3Activity.kt | 20 ++++++++++--------- .../navigation/nav3/Nav3CustomTransactions.kt | 4 +--- .../navigation/nav3/Nav3Performance.kt | 6 +++++- .../nav3/Nav3PerformanceControls.kt | 6 ++++++ 4 files changed, 23 insertions(+), 13 deletions(-) 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 b2a8d05720a..da90e0dd115 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 @@ -303,15 +303,17 @@ private fun Nav3SampleApp( ) if (isPerformanceScenario) { - performanceState.recordSentryNavEffect( - processedCall = true, - resolvedEntryCount = - if (integrationMode.captureBackStack) { - minOf(sentryBackStack.size, maxCapturedBackStackEntries) - } else { - 0 - }, - ) + SideEffect { + performanceState.recordSentryNavEffect( + processedCall = true, + resolvedEntryCount = + if (integrationMode.captureBackStack) { + minOf(sentryBackStack.size, maxCapturedBackStackEntries) + } else { + 0 + }, + ) + } } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt index 4ff777238b9..b12f5b75a85 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt @@ -129,9 +129,7 @@ internal class Nav3CustomTransactionController { private fun handleWholeFlow(currentRoute: Nav3Route) { if (currentRoute == Nav3Route.Custom) { - if (activeMode == Nav3CustomTransactionMode.WHOLE_FLOW) { - finishActiveTransaction() - } + finishActiveTransaction() return } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt index 3e77b9afa63..62c181b5b42 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt @@ -226,7 +226,11 @@ private suspend fun runNav3PerformanceAbComparison( } } finally { state.updateIntegrationMode(originalMode) - awaitNav3PerformanceFrames() + try { + awaitNav3PerformanceFrames() + } finally { + state.resetNavigationMeasurementGuards() + } } state.finishPerformanceRun(results.joinToString(" | ")) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt index c3ef2d68d07..90f90dd2b3a 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt @@ -378,6 +378,12 @@ internal class Nav3PerformanceState( pendingAbPhase = null } + fun resetNavigationMeasurementGuards() { + discardNextSentryNavEffectMeasurement = false + suppressExtractorMeasurementsUntilNavigation = false + pendingProcessedNavigationWork = false + } + private fun finishMeasuredPhase() { pendingMeasuredPhase?.let { phase -> endAsyncTraceSection(phase.sectionName, phase.cookie) } pendingMeasuredPhase = null From 763065c00f6aa50550f836a8f946fa844b09c1b6 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 6 Oct 2026 11:51:14 +0200 Subject: [PATCH 7/8] fix(samples): Record Nav3 metrics once per effect key Run performance counter updates once after SentryNavEffect setup for each back stack or integration-mode change. This preserves extractor guard ordering without including later Scaffold or NavDisplay work in timing measurements. Refs JAVA-274 Co-Authored-By: Codex --- .../io/sentry/samples/android/navigation/nav3/Nav3Activity.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 da90e0dd115..5b935a01e01 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 @@ -25,6 +25,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue @@ -303,7 +304,7 @@ private fun Nav3SampleApp( ) if (isPerformanceScenario) { - SideEffect { + DisposableEffect(sentryBackStack.toList(), sentryNavOptions) { performanceState.recordSentryNavEffect( processedCall = true, resolvedEntryCount = @@ -313,6 +314,7 @@ private fun Nav3SampleApp( 0 }, ) + onDispose {} } } } From 8044d13d9fd09eab345828d6e9e922a6e2334354 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 6 Oct 2026 11:55:43 +0200 Subject: [PATCH 8/8] fix(samples): Correct remaining Nav3 sample findings Let the uncaught exception handler report crash-button failures so deduplication cannot turn them into handled events. Recognize Nav3 scenario tags when labeling transactions in the history sheet. Refs JAVA-274 Co-Authored-By: Codex --- .../samples/android/navigation/nav3/Nav3RouteScreens.kt | 5 +---- .../samples/android/navigation/nav3/Nav3SampleTracing.kt | 2 ++ 2 files changed, 3 insertions(+), 4 deletions(-) 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 48c0c171ad3..1d4b933fdc3 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 @@ -177,10 +177,7 @@ internal fun Nav3SentryButton( } internal fun crashSampleApp(navName: String): Nothing { - val crash = RuntimeException("Fatal $navName sample crash app button") - Sentry.captureException(crash) - Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) - throw crash + throw RuntimeException("Fatal $navName sample crash app button") } private fun runManualNav3RouteActivationSpan(route: Nav3Route) { 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 802757a06d6..4a9b77227b1 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 @@ -18,6 +18,7 @@ internal fun tagCurrentNavigationSampleScenario(scenarioLabel: String) { internal fun SentryTransaction.navigationSampleScenarioLabel(): String = getTag(NAVIGATION_SAMPLE_SCENARIO_TAG) ?: getTag(NAV2_SCENARIO_TAG) + ?: getTag(NAV3_SCENARIO_TAG) ?: UNKNOWN_NAVIGATION_SCENARIO_LABEL internal fun cancelCurrentActivityUiLoadTransaction() { @@ -72,6 +73,7 @@ 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" 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"