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..8e89f1e4380 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -101,6 +101,17 @@ 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..2067e4e8d1d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2Activity.kt @@ -0,0 +1,639 @@ +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.ViewCompat +import androidx.core.view.WindowInsetsCompat +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 + } + + sentryNavigationListener = createSentryNavigationListener() + + 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) + } + + 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() + super.onStop() + } + + override fun onDestroy() { + if (isFinishing) { + previousConfig.applyToCurrentOptions() + } + mainHandler.removeCallbacksAndMessages(null) + transactionHistory.uninstall() + super.onDestroy() + } + + internal fun navigateTo(destination: Nav2Destination) { + backStack.add(destination) + try { + navController.navigate(destination.id, destination.arguments) + } catch (e: IllegalArgumentException) { + backStack.removeAt(backStack.lastIndex) + throw e + } catch (e: IllegalStateException) { + 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( + navListener = sentryNavigationListener, + 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() + ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(0, systemBars.top, 0, systemBars.bottom) + 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..2bb8c359f11 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt @@ -0,0 +1,698 @@ +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.android.navigation.SentryNavigationListener +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( + navListener: SentryNavigationListener, + routeWorkOptions: Set, + onCaptureException: () -> Unit, + onCrashApp: () -> Unit, + onRouteChanged: (routeName: String, currentRoute: String, backStack: String) -> Unit, +) { + + val navController = rememberNavController().withSentryObservableEffect(navListener = navListener) + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + +