diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c73ae6a25..2e9ddb0bd9 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 99d0edcd1c..b065b91e95 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 40e5bbc982..4fee4a1b92 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 9b10327462..4e80ddfd86 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 54214b8ff5..9660f52ed3 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 0000000000..848720b019 --- /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 a846e51b8e..f64a78b051 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 0000000000..5b935a01e0 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt @@ -0,0 +1,603 @@ +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.DisposableEffect +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, + ) + + SentryNavEffect( + backStack = sentryBackStack, + backStackEntryMapper = backStackEntryMapper, + options = sentryNavOptions, + ) + + if (isPerformanceScenario) { + DisposableEffect(sentryBackStack.toList(), sentryNavOptions) { + performanceState.recordSentryNavEffect( + processedCall = true, + resolvedEntryCount = + if (integrationMode.captureBackStack) { + minOf(sentryBackStack.size, maxCapturedBackStackEntries) + } else { + 0 + }, + ) + onDispose {} + } + } + } + + 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 0000000000..2ff41d9684 --- /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 0000000000..b12f5b75a8 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3CustomTransactions.kt @@ -0,0 +1,220 @@ +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) { + 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 0000000000..62c181b5b4 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Performance.kt @@ -0,0 +1,283 @@ +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) + try { + awaitNav3PerformanceFrames() + } finally { + state.resetNavigationMeasurementGuards() + } + } + + 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 0000000000..90f90dd2b3 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3PerformanceControls.kt @@ -0,0 +1,1272 @@ +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 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 suppressExtractorMeasurementsUntilNavigation = 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 + pendingProcessedNavigationWork = false + 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++ + markNavigationWorkPending() + if (!performanceRunActive) { + displayRevision++ + } + } + + fun markDestinationChange() { + if (suppressNextDestinationChange) { + suppressNextDestinationChange = false + return + } + destinationChanges++ + markNavigationWorkPending() + if (!performanceRunActive) { + displayRevision++ + } + } + + fun markPerformanceDestinationChange() { + destinationChanges++ + markNavigationWorkPending() + } + + fun suppressNextDestinationChange() { + suppressNextDestinationChange = true + } + + fun updateIntegrationMode(mode: Nav3PerformanceIntegrationMode) { + if (integrationMode != mode) { + discardNextSentryNavEffectMeasurement = true + suppressExtractorMeasurementsUntilNavigation = true + pendingProcessedNavigationWork = false + 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( + processedCall: Boolean, + resolvedEntryCount: Int, + ) { + if (discardNextSentryNavEffectMeasurement) { + discardNextSentryNavEffectMeasurement = false + pendingProcessedNavigationWork = false + return + } + if (!collectMeasurements) { + return + } + + sentryNavEffectAttempts++ + val shouldCountAsProcessed = processedCall && pendingProcessedNavigationWork + if (shouldCountAsProcessed) { + sentryNavEffectProcessedCalls++ + capturedEntriesResolved += resolvedEntryCount + } + pendingProcessedNavigationWork = false + } + + 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() + cancelPerformanceRun(status = "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: 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("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 + } + + fun resetNavigationMeasurementGuards() { + discardNextSentryNavEffectMeasurement = false + suppressExtractorMeasurementsUntilNavigation = false + pendingProcessedNavigationWork = false + } + + private fun finishMeasuredPhase() { + pendingMeasuredPhase?.let { phase -> endAsyncTraceSection(phase.sectionName, phase.cookie) } + pendingMeasuredPhase = null + } + + 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 && !suppressExtractorMeasurementsUntilNavigation) { + 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 && !suppressExtractorMeasurementsUntilNavigation) { + argumentsExtractorCalls++ + argumentsExtractorNanos += System.nanoTime() - startedAt + } + } + } + + private fun markNavigationWorkPending() { + suppressExtractorMeasurementsUntilNavigation = false + pendingProcessedNavigationWork = true + } +} + +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( + "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 0000000000..1d4b933fdc --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt @@ -0,0 +1,727 @@ +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 { + throw RuntimeException("Fatal $navName sample crash app button") +} + +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 0000000000..b9972a6cd7 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt @@ -0,0 +1,300 @@ +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" -> { + val productId = getString("product_id") ?: return Nav3Route.SingleStack + val source = getString("source") ?: return Nav3Route.SingleStack + Nav3Route.ProductDetail( + productId = productId, + source = source, + campaign = getString("campaign"), + ) + } + "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" -> + 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 0000000000..45eadc367c --- /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 0000000000..c24dbe707c --- /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 0000000000..4a9b77227b --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt @@ -0,0 +1,79 @@ +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) + ?: getTag(NAV3_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 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" 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 0000000000..db945d4519 --- /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 0000000000..d2872f3c93 --- /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 0000000000..38cd6d2039 --- /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 0000000000..0e0038083e --- /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"