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