From b94d6f3d950535fd3efa8e1a900447830588a376 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 2 Oct 2026 16:39:23 +0200 Subject: [PATCH 1/4] ref(android): Extract shared nav sample app infrastructure into .common package Move shared navigation sample infrastructure into the io.sentry.samples.android.navigation.common package and isolate the existing Nav2 implementation under its own package. Keep Nav2 performance controls focused while wiring route-work support and launcher resources for the sample foundation. Lays the groundwork for the Nav3 sample app. Co-Authored-By: Codex --- .../src/main/AndroidManifest.xml | 4 +- .../sentry/samples/android/GitHubService.kt | 6 + .../io/sentry/samples/android/GithubAPI.kt | 2 +- .../io/sentry/samples/android/MainActivity.kt | 5 +- .../sentry/samples/android/SecondActivity.kt | 2 +- .../sentry/samples/android/ThirdFragment.kt | 2 +- .../android/compose/ComposeActivity.kt | 6 +- .../samples/android/navigation/Nav2Routes.kt | 400 ------------------ .../android/navigation/Nav2SetupActivity.kt | 297 ------------- .../common/NavigationRouteDisplay.kt | 32 ++ .../navigation/common/NavigationRouteSpec.kt | 145 +++++++ .../NavigationRouteWorkDialog.kt} | 7 +- .../common/NavigationSampleConfig.kt | 63 +++ .../common/NavigationSetupScreen.kt | 393 +++++++++++++++++ .../NavigationThemeColors.kt} | 3 +- .../android/navigation/common/RouteWorkApi.kt | 50 +++ .../navigation/common/RouteWorkOption.kt | 7 + .../navigation/{ => nav2}/Nav2Activity.kt | 88 ++-- .../{ => nav2}/Nav2ComposeRoutes.kt | 383 +++++++++++++---- .../navigation/{ => nav2}/Nav2ContentHosts.kt | 2 +- .../navigation/nav2/Nav2CustomTransactions.kt | 205 +++++++++ .../{ => nav2}/Nav2ModalFragments.kt | 18 +- .../Nav2PerformanceControls.kt} | 16 +- .../{ => nav2}/Nav2RouteFragment.kt | 48 ++- .../android/navigation/nav2/Nav2Routes.kt | 217 ++++++++++ .../Nav2SampleConfigAdapter.kt} | 115 +++-- .../navigation/{ => nav2}/Nav2SampleTheme.kt | 2 +- .../navigation/nav2/Nav2SetupActivity.kt | 44 ++ .../navigation/{ => nav2}/Nav2TopBar.kt | 31 +- .../{ => nav2}/Nav2TransactionHistory.kt | 2 +- .../{ => nav2}/Nav2TransactionHistorySheet.kt | 2 +- .../src/main/res/navigation/nav2_sample.xml | 18 +- .../src/main/res/values/ids.xml | 1 + 33 files changed, 1654 insertions(+), 962 deletions(-) delete mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2Routes.kt delete mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SetupActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationRouteDisplay.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationRouteSpec.kt rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{Nav2RouteWorkDialog.kt => common/NavigationRouteWorkDialog.kt} (88%) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationSampleConfig.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationSetupScreen.kt rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{Nav2ThemeColors.kt => common/NavigationThemeColors.kt} (71%) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkApi.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkOption.kt rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{ => nav2}/Nav2Activity.kt (87%) rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{ => nav2}/Nav2ComposeRoutes.kt (60%) rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{ => nav2}/Nav2ContentHosts.kt (97%) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2CustomTransactions.kt rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{ => nav2}/Nav2ModalFragments.kt (91%) rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{NavigationPerformanceControls.kt => nav2/Nav2PerformanceControls.kt} (94%) rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{ => nav2}/Nav2RouteFragment.kt (86%) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Routes.kt rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{Nav2SampleConfig.kt => nav2/Nav2SampleConfigAdapter.kt} (57%) rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{ => nav2}/Nav2SampleTheme.kt (95%) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2SetupActivity.kt rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{ => nav2}/Nav2TopBar.kt (89%) rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{ => nav2}/Nav2TransactionHistory.kt (99%) rename sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/{ => nav2}/Nav2TransactionHistorySheet.kt (99%) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index cf0bc55e5e0..9b103274623 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -102,12 +102,12 @@ android:exported="false" /> diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GitHubService.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GitHubService.kt index 1e8b5975c71..d5a1b8e4217 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GitHubService.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GitHubService.kt @@ -1,9 +1,11 @@ package io.sentry.samples.android +import okhttp3.ResponseBody import retrofit2.Call import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query +import retrofit2.http.Url interface GitHubService { @@ -14,6 +16,10 @@ interface GitHubService { @Path("user") user: String, @Query("per_page") perPage: Int, ): List + + @GET fun routeWorkRequest(@Url url: String): Call + + @GET suspend fun routeWorkRequestAsync(@Url url: String): ResponseBody } class Repo { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GithubAPI.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GithubAPI.kt index eb75569fff6..df3ee7d33fa 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GithubAPI.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GithubAPI.kt @@ -7,7 +7,7 @@ import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory -object GithubAPI { +object GitHubApi { private val client = OkHttpClient.Builder() .eventListener(SentryOkHttpEventListener()) 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 31bf86c7db2..54214b8ff55 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 @@ -900,7 +900,10 @@ fun IntegrationsScreen() { OutlinedButton( onClick = { activity.startActivity( - Intent(activity, io.sentry.samples.android.navigation.Nav2SetupActivity::class.java) + Intent( + activity, + io.sentry.samples.android.navigation.nav2.Nav2SetupActivity::class.java, + ) ) }, modifier = Modifier, diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/SecondActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/SecondActivity.kt index d46573855d6..b8dec7e1059 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/SecondActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/SecondActivity.kt @@ -67,7 +67,7 @@ class SecondActivity : AppCompatActivity() { currentSpan?.startChild("updateRepos", javaClass.simpleName) ?: Sentry.startTransaction("updateRepos", "task") - GithubAPI.service + GitHubApi.service .listRepos(binding.editRepo.text.toString()) .enqueue( object : Callback> { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ThirdFragment.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ThirdFragment.kt index 28b30e78acb..a99185eb48f 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ThirdFragment.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ThirdFragment.kt @@ -15,7 +15,7 @@ class ThirdFragment : Fragment(R.layout.third_fragment) { val span = Sentry.getSpan() val child = span?.startChild("calc") - GithubAPI.service + GitHubApi.service .listRepos("getsentry") .enqueue( object : Callback> { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index b27da13c770..4fd1d74ef7a 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -53,7 +53,7 @@ import io.sentry.android.replay.sentryReplayUnmask import io.sentry.compose.SentryTraced import io.sentry.compose.SentryUserFeedbackButton import io.sentry.compose.withSentryObservableEffect -import io.sentry.samples.android.GithubAPI +import io.sentry.samples.android.GitHubApi import io.sentry.samples.android.R as IR import io.sentry.samples.android.SharedState import kotlinx.coroutines.launch @@ -160,7 +160,7 @@ fun Github(user: String = "getsentry", perPage: Int = 30) { LaunchedEffect(perPage) { result = try { - GithubAPI.service.listReposAsync(user.text, perPage).random().full_name + GitHubApi.service.listReposAsync(user.text, perPage).random().full_name } catch (e: Throwable) { "error" } @@ -190,7 +190,7 @@ fun Github(user: String = "getsentry", perPage: Int = 30) { scope.launch { result = try { - GithubAPI.service.listReposAsync(user.text, perPage).random().full_name + GitHubApi.service.listReposAsync(user.text, perPage).random().full_name } catch (e: Throwable) { "error" } 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 deleted file mode 100644 index 7b278159d81..00000000000 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2Routes.kt +++ /dev/null @@ -1,400 +0,0 @@ -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/Nav2SetupActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SetupActivity.kt deleted file mode 100644 index cdd0c35f40e..00000000000 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SetupActivity.kt +++ /dev/null @@ -1,297 +0,0 @@ -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 { - Nav2SampleTheme { - 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/common/NavigationRouteDisplay.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationRouteDisplay.kt new file mode 100644 index 00000000000..ce5aa3110d7 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationRouteDisplay.kt @@ -0,0 +1,32 @@ +package io.sentry.samples.android.navigation.common + +import android.os.Bundle + +internal fun RouteSpec.displayArguments(arguments: Bundle?): List> = + displayedArguments.mapNotNull { argument -> + argument.toDisplayPair(arguments?.getString(argument.key)) + } + +internal fun RouteSpec.displayArguments(arguments: Map): List> = + displayedArguments.mapNotNull { argument -> + argument.toDisplayPair(arguments[argument.key]?.toString()) + } + +internal fun RouteSpec.displayRoute(arguments: Bundle?): String = + displayRoute(displayArguments(arguments)) + +internal fun RouteSpec.displayRoute(arguments: Map): String = + displayRoute(displayArguments(arguments)) + +internal fun List>.toDisplayString(): String = + joinToString(", ") { (label, value) -> "$label=$value" } + +private fun RouteSpec.displayRoute(displayArguments: List>): String = + if (displayArguments.isEmpty()) { + "/$routeName" + } else { + "/$routeName { ${displayArguments.toDisplayString()} }" + } + +private fun DisplayedArgument.toDisplayPair(value: String?): Pair? = + value?.takeIf { it.isNotEmpty() }?.let { label to it } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationRouteSpec.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationRouteSpec.kt new file mode 100644 index 00000000000..46d22f08bbf --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationRouteSpec.kt @@ -0,0 +1,145 @@ +package io.sentry.samples.android.navigation.common + +internal object RouteNames { + + const val LANDING = "Landing" + const val HOME = "Home" + const val CUSTOM = "Custom" + 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 NavArgs { + + 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 DisplayedArgument(val key: String, val label: String = key) + +internal data class RouteSpec( + val routeName: String, + val title: String, + val description: String? = null, + val displayedArguments: List = emptyList(), +) + +internal object RouteSpecs { + val landing = + RouteSpec( + routeName = RouteNames.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 = + RouteSpec( + routeName = RouteNames.HOME, + title = "Home", + description = + "Start a product flow, then use the Sentry UI to inspect route " + + "transactions, breadcrumbs, and screen tracking.", + ) + + val custom = + home.copy( + routeName = RouteNames.CUSTOM, + title = "Custom Transactions", + description = + "Reuses the single-stack shopping flow while simulating a power user who starts their " + + "own manual transactions.", + ) + + val deepLink = + RouteSpec( + routeName = RouteNames.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 = + RouteSpec( + routeName = RouteNames.PRODUCT_LIST, + title = "Product List", + description = "This route starts the product journey.", + ) + + val productDetail = + RouteSpec( + routeName = RouteNames.PRODUCT_DETAIL, + title = "Product Detail", + description = "", + displayedArguments = + listOf( + DisplayedArgument(NavArgs.PRODUCT_ID, "productId"), + DisplayedArgument(NavArgs.SOURCE), + DisplayedArgument(NavArgs.CAMPAIGN), + ), + ) + + val checkout = + RouteSpec( + routeName = RouteNames.CHECKOUT, + title = "Checkout", + description = "", + displayedArguments = listOf(DisplayedArgument(NavArgs.PRODUCT_ID, "productId")), + ) + + val confirmation = + RouteSpec( + routeName = RouteNames.CONFIRMATION, + title = "Confirmation", + description = "End of the product flow.", + displayedArguments = listOf(DisplayedArgument(NavArgs.ORDER_ID, "orderId")), + ) + + val promoDialog = + RouteSpec( + routeName = RouteNames.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(DisplayedArgument(NavArgs.PROMO_ID, "promoId")), + ) + + val shareSheet = + RouteSpec( + routeName = RouteNames.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(DisplayedArgument(NavArgs.PRODUCT_ID, "productId")), + ) + + fun get(routeName: String): RouteSpec = + when (routeName) { + RouteNames.LANDING -> landing + RouteNames.HOME -> home + RouteNames.CUSTOM -> custom + RouteNames.DEEP_LINK -> deepLink + RouteNames.PRODUCT_LIST -> productList + RouteNames.PRODUCT_DETAIL -> productDetail + RouteNames.CHECKOUT -> checkout + RouteNames.CONFIRMATION -> confirmation + RouteNames.PROMO_DIALOG -> promoDialog + RouteNames.SHARE_SHEET -> shareSheet + else -> RouteSpec(routeName = routeName, title = routeName) + } +} 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/common/NavigationRouteWorkDialog.kt similarity index 88% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteWorkDialog.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationRouteWorkDialog.kt index 31b7e39f77b..bfddc3129e1 100644 --- 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/common/NavigationRouteWorkDialog.kt @@ -1,5 +1,6 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.common +import android.R import android.content.Context import android.graphics.Typeface import android.view.View @@ -39,14 +40,14 @@ private fun routeWorkDialogTitle(context: Context): View = text = "Route work" textSize = 20f setTypeface(null, Typeface.BOLD) - setTextColor(context.themeColor(android.R.attr.textColorPrimary)) + setTextColor(context.themeColor(R.attr.textColorPrimary)) } ) addView( TextView(context).apply { text = "Enable/disable the generation of spans by navigation destinations." textSize = 14f - setTextColor(context.themeColor(android.R.attr.textColorSecondary)) + setTextColor(context.themeColor(R.attr.textColorSecondary)) setPadding(0, context.dp(8), 0, 0) } ) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationSampleConfig.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationSampleConfig.kt new file mode 100644 index 00000000000..61bde68b2a3 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationSampleConfig.kt @@ -0,0 +1,63 @@ +package io.sentry.samples.android.navigation.common + +import io.sentry.Sentry + +/** Configuration shared by nav sample apps. */ +internal data class NavigationSampleConfig( + val enableNavigationTransactions: Boolean = true, + val enableNavigationBreadcrumbs: Boolean = true, + val enableScreenTracking: Boolean = true, + val enableActivityUiLoadTransaction: Boolean = false, + val enableUserInteractionTransactions: Boolean = false, + val enableUserInteractionBreadcrumbs: Boolean = false, + val captureBackStack: Boolean = true, + val maxCapturedBackStackEntries: Int = 10, +) + +internal val NavigationSampleConfig.hasOnlyActivityUiLoadTransactions: Boolean + get() = + enableActivityUiLoadTransaction && + !enableNavigationTransactions && + !enableUserInteractionTransactions + +internal data class NavigationSampleConfigSnapshot( + val enableScreenTracking: Boolean, + val enableUserInteractionTransactions: Boolean, + val enableUserInteractionBreadcrumbs: Boolean, +) + +internal fun NavigationSampleConfig.applyToCurrentOptions() { + applyNavigationSampleOptions( + enableScreenTracking = enableScreenTracking, + enableUserInteractionTransactions = enableUserInteractionTransactions, + enableUserInteractionBreadcrumbs = enableUserInteractionBreadcrumbs, + ) +} + +internal fun NavigationSampleConfigSnapshot.applyToCurrentOptions() { + applyNavigationSampleOptions( + enableScreenTracking = enableScreenTracking, + enableUserInteractionTransactions = enableUserInteractionTransactions, + enableUserInteractionBreadcrumbs = enableUserInteractionBreadcrumbs, + ) +} + +private fun applyNavigationSampleOptions( + enableScreenTracking: Boolean, + enableUserInteractionTransactions: Boolean, + enableUserInteractionBreadcrumbs: Boolean, +) { + val options = Sentry.getCurrentScopes().options + options.setEnableScreenTracking(enableScreenTracking) + options.setEnableUserInteractionTracing(enableUserInteractionTransactions) + options.setEnableUserInteractionBreadcrumbs(enableUserInteractionBreadcrumbs) +} + +internal fun currentNavigationSampleConfigSnapshot(): NavigationSampleConfigSnapshot { + val options = Sentry.getCurrentScopes().options + return NavigationSampleConfigSnapshot( + enableScreenTracking = options.isEnableScreenTracking, + enableUserInteractionTransactions = options.isEnableUserInteractionTracing, + enableUserInteractionBreadcrumbs = options.isEnableUserInteractionBreadcrumbs, + ) +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationSetupScreen.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationSetupScreen.kt new file mode 100644 index 00000000000..bef3414ab4f --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationSetupScreen.kt @@ -0,0 +1,393 @@ +package io.sentry.samples.android.navigation.common + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.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.CircleShape +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.ButtonDefaults +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.Surface +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.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +internal fun NavigationSetupScreen( + navName: String, + navVersion: String, + configuration: NavigationSampleConfig, + showBackStackControls: Boolean = false, + onConfigurationChanged: (NavigationSampleConfig) -> 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 $navVersion Setup", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + + Text( + text = + "Choose which auto-instrumentation features should be active before the $navName sample launches.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + NavigationSetupSection(title = "Navigation") { + NavigationSetupCheckboxRow( + label = "Navigation transactions", + checked = configuration.enableNavigationTransactions, + ) { + onConfigurationChanged(configuration.copy(enableNavigationTransactions = it)) + } + NavigationSetupCheckboxRow( + label = "Navigation breadcrumbs", + checked = configuration.enableNavigationBreadcrumbs, + ) { + onConfigurationChanged(configuration.copy(enableNavigationBreadcrumbs = it)) + } + NavigationSetupCheckboxRow( + label = "Screen tracking", + checked = configuration.enableScreenTracking, + ) { + onConfigurationChanged(configuration.copy(enableScreenTracking = it)) + } + if (showBackStackControls) { + NavigationSetupCheckboxRow( + label = "Capture back stack", + checked = configuration.captureBackStack, + ) { + onConfigurationChanged(configuration.copy(captureBackStack = it)) + } + NavigationSetupCounterRow( + label = "Max captured back stack entries", + value = configuration.maxCapturedBackStackEntries, + enabled = configuration.captureBackStack, + ) { + onConfigurationChanged(configuration.copy(maxCapturedBackStackEntries = it)) + } + } + } + + NavigationSetupSection(title = "Other auto-transactions") { + NavigationSetupCheckboxRow( + label = "Activity ui.load transaction", + checked = configuration.enableActivityUiLoadTransaction, + helpText = ACTIVITY_UI_LOAD_HELP_TEXT, + ) { + onConfigurationChanged(configuration.copy(enableActivityUiLoadTransaction = it)) + } + NavigationSetupCheckboxRow( + label = "User interaction transactions", + checked = configuration.enableUserInteractionTransactions, + ) { + onConfigurationChanged(configuration.copy(enableUserInteractionTransactions = it)) + } + } + + NavigationSetupSection(title = "Other breadcrumbs") { + NavigationSetupCheckboxRow( + label = "User interaction breadcrumbs", + checked = configuration.enableUserInteractionBreadcrumbs, + ) { + onConfigurationChanged(configuration.copy(enableUserInteractionBreadcrumbs = it)) + } + } + + Button(onClick = onLaunch, modifier = Modifier.fillMaxWidth()) { + Text("Launch $navName Sample", fontSize = 18.sp) + } + } +} + +@Composable +private fun NavigationSetupSection(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 NavigationSetupCheckboxRow( + 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.12f) else MaterialTheme.colorScheme.surface, + animationSpec = spring(stiffness = 550f, dampingRatio = 0.9f), + label = "navigation-setup-toggle-background", + ) + val rowBorderColor by + animateColorAsState( + targetValue = + if (checked) sentryPink.copy(alpha = 0.35f) + else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f), + animationSpec = tween(durationMillis = 180), + label = "navigation-setup-toggle-border", + ) + val switchColors = + SwitchDefaults.colors( + checkedTrackColor = sentryPink, + checkedBorderColor = sentryPink, + checkedThumbColor = Color.White, + ) + + Surface( + shape = rowShape, + color = rowBackground, + border = BorderStroke(1.dp, rowBorderColor), + tonalElevation = if (checked) 1.dp else 0.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier.fillMaxWidth() + .toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange) + .defaultMinSize(minHeight = 56.dp) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Switch( + checked = checked, + onCheckedChange = null, + colors = switchColors, + ) + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Text(text = label, style = MaterialTheme.typography.titleMedium) + if (helpText != null) { + NavigationSetupHelpTooltip(helpText) + } + } + } + } +} + +@Composable +private fun NavigationSetupCounterRow( + label: String, + value: Int, + enabled: Boolean, + onValueChange: (Int) -> Unit, +) { + val sentryPurple = Color(0xFF6C55B2) + val containerColor by + animateColorAsState( + targetValue = + if (enabled) MaterialTheme.colorScheme.surface + else MaterialTheme.colorScheme.surfaceVariant, + animationSpec = tween(durationMillis = 180), + label = "navigation-setup-counter-background", + ) + val borderColor by + animateColorAsState( + targetValue = + if (enabled) sentryPurple.copy(alpha = 0.28f) + else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + animationSpec = tween(durationMillis = 180), + label = "navigation-setup-counter-border", + ) + + Surface( + shape = RoundedCornerShape(12.dp), + color = containerColor, + border = BorderStroke(1.dp, borderColor), + tonalElevation = if (enabled) 1.dp else 0.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier.fillMaxWidth() + .defaultMinSize(minHeight = 56.dp) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + + NavigationSetupCounterButton( + label = "-", + enabled = enabled && value > 1, + onClick = { onValueChange((value - 1).coerceAtLeast(1)) }, + color = sentryPurple, + ) + + Box(modifier = Modifier.size(44.dp), contentAlignment = Alignment.Center) { + AnimatedContent( + targetState = value, + transitionSpec = { + (slideInVertically { fullHeight -> fullHeight / 3 } + fadeIn()) togetherWith + (slideOutVertically { fullHeight -> -fullHeight / 3 } + fadeOut()) using + SizeTransform(clip = false) + }, + label = "navigation-setup-counter-value", + ) { animatedValue -> + Text( + text = animatedValue.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + } + } + + NavigationSetupCounterButton( + label = "+", + enabled = enabled, + onClick = { onValueChange(value + 1) }, + color = sentryPurple, + ) + } + } +} + +@Composable +private fun NavigationSetupCounterButton( + label: String, + enabled: Boolean, + color: Color, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + shape = CircleShape, + contentPadding = ButtonDefaults.ContentPadding, + colors = + ButtonDefaults.buttonColors( + containerColor = color, + contentColor = Color.White, + disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ), + elevation = + ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, + pressedElevation = 2.dp, + focusedElevation = 1.dp, + hoveredElevation = 1.dp, + disabledElevation = 0.dp, + ), + modifier = Modifier.size(48.dp), + ) { + Text(label, style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NavigationSetupHelpTooltip(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 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/Nav2ThemeColors.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationThemeColors.kt similarity index 71% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ThemeColors.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationThemeColors.kt index 6f5d99b5363..a92e107c281 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ThemeColors.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/NavigationThemeColors.kt @@ -1,8 +1,9 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.common import android.content.Context import androidx.annotation.AttrRes +/** Resolves a theme color for navigation sample views. */ internal fun Context.themeColor(@AttrRes attrId: Int): Int { val attributes = obtainStyledAttributes(intArrayOf(attrId)) return try { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkApi.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkApi.kt new file mode 100644 index 00000000000..8a4d337a373 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkApi.kt @@ -0,0 +1,50 @@ +package io.sentry.samples.android.navigation.common + +import io.sentry.HttpStatusCodeRange +import io.sentry.okhttp.SentryOkHttpEventListener +import io.sentry.okhttp.SentryOkHttpInterceptor +import okhttp3.OkHttpClient +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Url + +internal object RouteWorkApi { + private val client = + OkHttpClient.Builder() + .eventListener(SentryOkHttpEventListener()) + .addInterceptor( + SentryOkHttpInterceptor( + captureFailedRequests = true, + failedRequestStatusCodes = listOf(HttpStatusCodeRange(400, 599)), + ) + ) + .build() + + private val retrofit = + Retrofit.Builder() + .baseUrl("https://httpbin.org/") + .addConverterFactory(GsonConverterFactory.create()) + .client(client) + .build() + + private val service: RouteWorkService = retrofit.create(RouteWorkService::class.java) + + fun enqueueRequest(callback: retrofit2.Callback) { + service.request(ROUTE_WORK_URL).enqueue(callback) + } + + suspend fun runRequest() { + service.requestAsync(ROUTE_WORK_URL) + } +} + +internal interface RouteWorkService { + @GET fun request(@Url url: String): Call + + @GET suspend fun requestAsync(@Url url: String): ResponseBody +} + +private const val ROUTE_WORK_URL = "https://httpbin.org/get?sentry_sample=navigation_route_work" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkOption.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkOption.kt new file mode 100644 index 00000000000..1c8371c3e55 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkOption.kt @@ -0,0 +1,7 @@ +package io.sentry.samples.android.navigation.common + +/** Optional work navigation sample routes can perform after they become active. */ +internal enum class RouteWorkOption(val label: String, val tagName: String) { + HTTP_REQUEST("HTTP request", "http_request"), + MANUAL_CHILD_SPAN("Manual child span", "manual_child_span"), +} 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/nav2/Nav2Activity.kt similarity index 87% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2Activity.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Activity.kt index 70fb0fe0f3b..20a504a40b4 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2Activity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Activity.kt @@ -1,4 +1,4 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.nav2 import android.content.ClipData import android.content.ClipboardManager @@ -33,11 +33,19 @@ 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 io.sentry.samples.android.navigation.common.NavigationSampleConfigSnapshot +import io.sentry.samples.android.navigation.common.RouteNames +import io.sentry.samples.android.navigation.common.RouteSpecs +import io.sentry.samples.android.navigation.common.RouteWorkApi +import io.sentry.samples.android.navigation.common.RouteWorkOption +import io.sentry.samples.android.navigation.common.applyToCurrentOptions +import io.sentry.samples.android.navigation.common.displayRoute +import io.sentry.samples.android.navigation.common.hasOnlyActivityUiLoadTransactions +import io.sentry.samples.android.navigation.common.showRouteWorkDialog +import io.sentry.samples.android.navigation.nav2.Nav2Destination.Home +import io.sentry.samples.android.navigation.nav2.Nav2Destination.Landing +import okhttp3.ResponseBody import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -66,17 +74,17 @@ class Nav2Activity : AppCompatActivity() { private val enableNavigationBreadcrumbs = mutableStateOf(true) private val enableNavigationTransactions = mutableStateOf(true) - private lateinit var previousConfig: Nav2SampleConfigSnapshot + private lateinit var previousConfig: NavigationSampleConfigSnapshot // Top bar config - private val routeWorkOptions = - mutableStateOf(setOf(RouteWorkOption.HTTP_REQUEST, RouteWorkOption.MANUAL_CHILD_SPAN)) + private val routeWorkOptions = mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN)) private lateinit var topBar: Nav2TopBar private var activeScenario = Nav2Scenario.COMPOSE + private val composeScenario = mutableStateOf(Nav2Scenario.COMPOSE) // Main content private lateinit var contentHosts: Nav2ContentHosts - private val performanceState = NavigationPerformanceState() + private val performanceState = Nav2PerformanceState() // Transaction history bottom sheet private var isTransactionHistoryActive = false @@ -203,9 +211,11 @@ class Nav2Activity : AppCompatActivity() { routeWorkOptions = routeWorkOptions.value, onCaptureException = { captureSampleException("Nav2") }, onCrashApp = { showCrashConfirmation("Nav2") }, + selectedScenario = composeScenario.value, onRouteChanged = { _, currentRoute, backStack -> updateComposeNavigationUi(currentRoute, backStack) }, + onExitRoot = { finish() }, ) } } @@ -258,15 +268,15 @@ class Nav2Activity : AppCompatActivity() { setContent { Nav2SampleTheme { - NavigationPerformancePanel( + Nav2PerformancePanel( 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}", + currentRoute = "/${backStack.lastOrNull()?.routeName ?: RouteNames.HOME}", backStack = - navigationPerformanceBackStackPreview( + nav2PerformanceBackStackPreview( backStack.map { destination -> "/${destination.routeName}" } ), state = performanceState, @@ -324,6 +334,7 @@ class Nav2Activity : AppCompatActivity() { private fun openScenario(scenario: Nav2Scenario) { activeScenario = scenario + composeScenario.value = scenario topBar.select(activeScenario) performanceState.stopAutomaticWork() when (scenario) { @@ -334,7 +345,14 @@ class Nav2Activity : AppCompatActivity() { navController.currentBackStackEntry?.arguments, ) } - Nav2Scenario.COMPOSE -> contentHosts.showCompose() + Nav2Scenario.COMPOSE -> { + contentHosts.showCompose() + updateComposeNavigationUi("/${RouteNames.HOME}", "/${RouteNames.HOME}") + } + Nav2Scenario.CUSTOM -> { + contentHosts.showCompose() + updateComposeNavigationUi("/${RouteNames.CUSTOM}", "/${RouteNames.CUSTOM}") + } Nav2Scenario.FRAGMENTS -> { contentHosts.showFragments() resetToHome() @@ -369,20 +387,18 @@ class Nav2Activity : AppCompatActivity() { 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() - } + RouteWorkApi.enqueueRequest( + 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) @@ -393,16 +409,16 @@ class Nav2Activity : AppCompatActivity() { private fun updatePerformanceTopBar() { topBar.update( scenario = Nav2Scenario.PERFORMANCE, - currentRoute = "/${backStack.lastOrNull()?.routeName ?: Nav2RouteNames.HOME}", + currentRoute = "/${backStack.lastOrNull()?.routeName ?: RouteNames.HOME}", backStack = - navigationPerformanceBackStackPreview( + nav2PerformanceBackStackPreview( backStack.map { destination -> "/${destination.routeName}" } ), ) } private fun buildNav2PerformanceStack() { - traceNavigationPerformanceSection("Nav2Stress.buildStack") { + traceNav2PerformanceSection("Nav2Stress.buildStack") { val generation = performanceState.nextGeneration() resetToHome() for (index in 1 until performanceState.stackDepth.coerceAtLeast(1)) { @@ -413,7 +429,7 @@ class Nav2Activity : AppCompatActivity() { } private fun replaceNav2PerformanceTop() { - traceNavigationPerformanceSection("Nav2Stress.replaceTop") { + traceNav2PerformanceSection("Nav2Stress.replaceTop") { val generation = performanceState.nextGeneration() if (backStack.size > 1) { navigateBack() @@ -456,9 +472,9 @@ class Nav2Activity : AppCompatActivity() { syncTrackedBackStack(destination, arguments) val trackedDestination = backStack.lastOrNull() - val routeName = trackedDestination?.routeName ?: destination?.routeName() ?: Nav2RouteNames.HOME + val routeName = trackedDestination?.routeName ?: destination?.routeName() ?: RouteNames.HOME val currentRoute = - trackedDestination?.displayRoute() ?: Nav2RouteSpecs.get(routeName).displayRoute(arguments) + trackedDestination?.displayRoute() ?: RouteSpecs.get(routeName).displayRoute(arguments) val scenario = fragmentTopBarScenario(trackedDestination) topBar.update( @@ -474,7 +490,9 @@ class Nav2Activity : AppCompatActivity() { private fun updateComposeNavigationUi(currentRoute: String, backStack: String) { topBar.update( - scenario = Nav2Scenario.COMPOSE, + scenario = + if (currentRoute.startsWith("/${RouteNames.CUSTOM}")) Nav2Scenario.CUSTOM + else Nav2Scenario.COMPOSE, currentRoute = currentRoute, backStack = backStack, ) @@ -605,7 +623,7 @@ class Nav2Activity : AppCompatActivity() { } internal fun captureSampleException(navName: String) { - Sentry.captureException(RuntimeException("$navName sample exception button")) + Sentry.captureException(RuntimeException("$navName sample capture exception button")) Thread { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }.start() } @@ -619,7 +637,7 @@ class Nav2Activity : AppCompatActivity() { } private fun crashSampleApp(navName: String): Nothing { - throw RuntimeException("Fatal $navName sample crash button") + throw RuntimeException("Fatal $navName sample crash app button") } private val Int.dp: Int 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/nav2/Nav2ComposeRoutes.kt similarity index 60% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ComposeRoutes.kt index ba61a6bb2ee..bb03639a7ee 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ComposeRoutes.kt @@ -1,7 +1,10 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.nav2 import android.os.Bundle import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope @@ -9,6 +12,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -23,6 +27,9 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -39,7 +46,9 @@ 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.res.colorResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.navigation.NavType @@ -53,15 +62,28 @@ 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 io.sentry.samples.android.R +import io.sentry.samples.android.navigation.common.NavArgs +import io.sentry.samples.android.navigation.common.RouteNames +import io.sentry.samples.android.navigation.common.RouteSpec +import io.sentry.samples.android.navigation.common.RouteSpecs +import io.sentry.samples.android.navigation.common.RouteWorkApi +import io.sentry.samples.android.navigation.common.RouteWorkOption +import io.sentry.samples.android.navigation.common.displayArguments +import io.sentry.samples.android.navigation.common.displayRoute +import io.sentry.samples.android.navigation.common.toDisplayString +import io.sentry.samples.android.navigation.nav2.Nav2ComposeDestination.Checkout +import io.sentry.samples.android.navigation.nav2.Nav2ComposeDestination.Confirmation +import io.sentry.samples.android.navigation.nav2.Nav2ComposeDestination.Custom +import io.sentry.samples.android.navigation.nav2.Nav2ComposeDestination.Home +import io.sentry.samples.android.navigation.nav2.Nav2ComposeDestination.ProductDetail +import io.sentry.samples.android.navigation.nav2.Nav2ComposeDestination.ProductList +import io.sentry.samples.android.navigation.nav2.Nav2ComposeDestination.PromoDialog import java.io.IOException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import retrofit2.HttpException @@ -71,13 +93,23 @@ internal fun Nav2ComposeApp( routeWorkOptions: Set, onCaptureException: () -> Unit, onCrashApp: () -> Unit, + selectedScenario: Nav2Scenario, onRouteChanged: (routeName: String, currentRoute: String, backStack: String) -> Unit, + onExitRoot: () -> Unit, ) { val navController = rememberNavController().withSentryObservableEffect(navListener = navListener) val backStack = rememberSaveableNav2ComposeBackStack() val shareSheetProductId = rememberSaveable { mutableStateOf(null) } val currentDestination = backStack.lastOrNull() ?: Home + var customTransactionMode by rememberSaveable { + mutableStateOf(Nav2CustomTransactionMode.PER_SCREEN) + } + var asyncBrowseProductsJob by rememberSaveable { mutableStateOf(null) } + var isAsyncBrowseProductsRunning by rememberSaveable { mutableStateOf(false) } + val customTransactionsScope = androidx.compose.runtime.rememberCoroutineScope() + val customTransactionController = + androidx.compose.runtime.remember { Nav2CustomTransactionController() } fun navigateTo(destination: Nav2ComposeDestination) { backStack.add(destination) @@ -108,8 +140,39 @@ internal fun Nav2ComposeApp( } } - BackHandler(enabled = shareSheetProductId.value != null) { dismissShareSheet() } - BackHandler(enabled = shareSheetProductId.value == null && backStack.size > 1) { navigateBack() } + LaunchedEffect(selectedScenario) { + when (selectedScenario) { + Nav2Scenario.COMPOSE -> { + customTransactionController.cleanup() + backStack.resetTo(Home) + shareSheetProductId.value = null + navController.navigate(Home.route) { + popUpTo(Home.route) { inclusive = true } + launchSingleTop = true + } + } + Nav2Scenario.CUSTOM -> { + backStack.resetTo(Custom) + shareSheetProductId.value = null + navController.navigate(Custom.route) { + popUpTo(Home.route) { inclusive = true } + launchSingleTop = true + } + } + else -> Unit + } + } + + LaunchedEffect(currentDestination, customTransactionMode) { + if ( + currentDestination != Custom && + customTransactionMode == Nav2CustomTransactionMode.ASYNC_FROM_USER_ACTION + ) { + asyncBrowseProductsJob?.cancel() + asyncBrowseProductsJob = null + isAsyncBrowseProductsRunning = false + } + } LaunchedEffect(currentDestination, backStack.size) { onRouteChanged( @@ -124,23 +187,70 @@ internal fun Nav2ComposeApp( routeWorkOptions = routeWorkOptions, ) + Nav2CustomTransactionEffect( + selectedDestination = currentDestination, + mode = customTransactionMode, + controller = customTransactionController, + ) + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column(modifier = Modifier.fillMaxSize()) { NavHost( navController = navController, startDestination = Home.route, modifier = Modifier.weight(1f), + enterTransition = { fadeIn(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS)) }, + exitTransition = { fadeOut(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS)) }, + popEnterTransition = { fadeIn(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS)) }, + popExitTransition = { fadeOut(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS)) }, ) { composable(Home.route) { TracedNav2ComposeRoute(Home.routeName) { - Nav2ComposeHomeRoute(routeSpec = Nav2RouteSpecs.home) { navigateTo(ProductList) } + Nav2ComposeHomeRoute(routeSpec = RouteSpecs.home) { navigateTo(ProductList) } + } + } + + composable(Custom.route) { + TracedNav2ComposeRoute(Custom.routeName) { + Nav2ComposeCustomRoute( + routeSpec = RouteSpecs.custom, + mode = customTransactionMode, + onModeSelected = { customTransactionMode = it }, + isAsyncBrowseProductsRunning = isAsyncBrowseProductsRunning, + onBrowseProducts = { + if (customTransactionMode == Nav2CustomTransactionMode.ASYNC_FROM_USER_ACTION) { + if (!isAsyncBrowseProductsRunning) { + customTransactionController.startAsyncBrowseProductsTransaction() + isAsyncBrowseProductsRunning = true + asyncBrowseProductsJob = customTransactionsScope.launch { + val span = + Sentry.getSpan() + ?.startChild( + "test.navigation.async_browse_products", + "Nav2 Custom async browse products", + ) + try { + delay(250) + navigateTo(ProductList) + } finally { + span?.finish() + isAsyncBrowseProductsRunning = false + asyncBrowseProductsJob = null + } + } + } + } else { + navigateTo(ProductList) + } + }, + ) } } composable(ProductList.route) { TracedNav2ComposeRoute(ProductList.routeName) { Nav2ComposeProductListRoute( - routeSpec = Nav2RouteSpecs.productList, + routeSpec = RouteSpecs.productList, onOpenProduct42 = { navigateTo( ProductDetail( @@ -161,20 +271,20 @@ internal fun Nav2ComposeApp( route = Nav2ComposeDestination.PRODUCT_DETAIL_ROUTE, arguments = listOf( - navArgument(Nav2Args.PRODUCT_ID) { type = NavType.StringType }, - navArgument(Nav2Args.SOURCE) { type = NavType.StringType }, - navArgument(Nav2Args.CAMPAIGN) { + navArgument(NavArgs.PRODUCT_ID) { type = NavType.StringType }, + navArgument(NavArgs.SOURCE) { type = NavType.StringType }, + navArgument(NavArgs.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) { + val productId = entry.arguments?.getString(NavArgs.PRODUCT_ID).orEmpty() + val source = entry.arguments?.getString(NavArgs.SOURCE).orEmpty() + val campaign = entry.arguments?.getString(NavArgs.CAMPAIGN).orEmpty() + TracedNav2ComposeRoute(RouteNames.PRODUCT_DETAIL) { Nav2ComposeProductDetailRoute( - routeSpec = Nav2RouteSpecs.productDetail, + routeSpec = RouteSpecs.productDetail, productId = productId, source = source, campaign = campaign, @@ -189,12 +299,12 @@ internal fun Nav2ComposeApp( composable( route = Nav2ComposeDestination.CHECKOUT_ROUTE, - arguments = listOf(navArgument(Nav2Args.PRODUCT_ID) { type = NavType.StringType }), + arguments = listOf(navArgument(NavArgs.PRODUCT_ID) { type = NavType.StringType }), ) { entry -> - val productId = entry.arguments?.getString(Nav2Args.PRODUCT_ID).orEmpty() - TracedNav2ComposeRoute(Nav2RouteNames.CHECKOUT) { + val productId = entry.arguments?.getString(NavArgs.PRODUCT_ID).orEmpty() + TracedNav2ComposeRoute(RouteNames.CHECKOUT) { Nav2ComposeCheckoutRoute( - routeSpec = Nav2RouteSpecs.checkout, + routeSpec = RouteSpecs.checkout, productId = productId, onCompleteOrder = { navigateTo(Confirmation(orderId = "order-$productId")) @@ -205,12 +315,12 @@ internal fun Nav2ComposeApp( composable( route = Nav2ComposeDestination.CONFIRMATION_ROUTE, - arguments = listOf(navArgument(Nav2Args.ORDER_ID) { type = NavType.StringType }), + arguments = listOf(navArgument(NavArgs.ORDER_ID) { type = NavType.StringType }), ) { entry -> - TracedNav2ComposeRoute(Nav2RouteNames.CONFIRMATION) { + TracedNav2ComposeRoute(RouteNames.CONFIRMATION) { Nav2ComposeConfirmationRoute( - routeSpec = Nav2RouteSpecs.confirmation, - orderId = entry.arguments?.getString(Nav2Args.ORDER_ID).orEmpty(), + routeSpec = RouteSpecs.confirmation, + orderId = entry.arguments?.getString(NavArgs.ORDER_ID).orEmpty(), onResetBackStack = { resetToHome() }, ) } @@ -218,15 +328,15 @@ internal fun Nav2ComposeApp( dialog( route = Nav2ComposeDestination.PROMO_DIALOG_ROUTE, - arguments = listOf(navArgument(Nav2Args.PROMO_ID) { type = NavType.StringType }), + arguments = listOf(navArgument(NavArgs.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) { + TracedNav2ComposeRoute(RouteNames.PROMO_DIALOG) { Nav2ComposePromoDialogRoute( - routeSpec = Nav2RouteSpecs.promoDialog, - promoId = entry.arguments?.getString(Nav2Args.PROMO_ID).orEmpty(), + routeSpec = RouteSpecs.promoDialog, + promoId = entry.arguments?.getString(NavArgs.PROMO_ID).orEmpty(), onCaptureException = onCaptureException, onCrashApp = onCrashApp, onDismiss = { navigateBack() }, @@ -235,12 +345,27 @@ internal fun Nav2ComposeApp( } } + // These BackHandlers are intentionally declared AFTER NavHost. NavHost installs its own + // internal BackHandler that pops the real NavController; if ours ran second it would let + // NavHost silently pop the controller while this sample's tracked back stack (which drives + // the header and the root-exit decision) went stale. Composing ours last gives it priority in + // the OnBackPressedDispatcher, so the tracked list and the NavController are only ever moved + // together, and backing out of the root reliably exits the activity. + BackHandler(enabled = shareSheetProductId.value != null) { dismissShareSheet() } + BackHandler(enabled = shareSheetProductId.value == null) { + if (backStack.size > 1) { + navigateBack() + } else { + onExitRoot() + } + } + 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, + routeSpec = RouteSpecs.shareSheet, productId = productId, onCaptureException = onCaptureException, onCrashApp = onCrashApp, @@ -299,7 +424,7 @@ private suspend fun runRouteWork( when (option) { RouteWorkOption.HTTP_REQUEST -> { try { - GithubAPI.service.listReposAsync("getsentry", 5) + RouteWorkApi.runRequest() } catch (e: IOException) { Sentry.captureException(e) } catch (e: HttpException) { @@ -314,14 +439,85 @@ private suspend fun runRouteWork( } @Composable -private fun Nav2ComposeHomeRoute(routeSpec: Nav2RouteSpec, onBrowseProducts: () -> Unit) { +private fun Nav2ComposeHomeRoute(routeSpec: RouteSpec, onBrowseProducts: () -> Unit) { Nav2ComposeActionRoute(routeSpec, buttons = listOf("Browse Products" to onBrowseProducts)) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Nav2ComposeCustomRoute( + routeSpec: RouteSpec, + mode: Nav2CustomTransactionMode, + onModeSelected: (Nav2CustomTransactionMode) -> Unit, + isAsyncBrowseProductsRunning: Boolean, + onBrowseProducts: () -> Unit, +) { + val helperText = + when (mode) { + Nav2CustomTransactionMode.PER_SCREEN -> + "Starts a custom transaction for every destination so route work runs under app-owned screen-level transactions." + Nav2CustomTransactionMode.WHOLE_FLOW -> + "Keeps one custom transaction open for the whole shopping journey until the flow returns to the Custom home screen." + Nav2CustomTransactionMode.ASYNC_FROM_USER_ACTION -> + "This mode starts a manual transaction from the button tap, waits for async work, and then pushes Product List." + Nav2CustomTransactionMode.LINGERING -> + "The lingering transaction stays active until you leave the Custom tab." + } + val sentryPink = colorResource(R.color.colorAccent) + + Nav2ComposeRouteScaffold( + routeSpec = routeSpec, + cardContent = { + Text("Mode", style = MaterialTheme.typography.titleSmall) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + Nav2CustomTransactionMode.entries.forEachIndexed { index, entry -> + SegmentedButton( + modifier = Modifier.weight(1f).defaultMinSize(minHeight = 72.dp), + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = Nav2CustomTransactionMode.entries.size, + ), + onClick = { onModeSelected(entry) }, + selected = mode == entry, + colors = + SegmentedButtonDefaults.colors( + activeContainerColor = sentryPink, + activeContentColor = Color.White, + ), + icon = {}, + label = { + Text( + text = entry.label, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2, + ) + }, + ) + } + } + Text(mode.description, style = MaterialTheme.typography.bodyMedium) + Nav2ComposeRouteButton( + label = + if ( + mode == Nav2CustomTransactionMode.ASYNC_FROM_USER_ACTION && isAsyncBrowseProductsRunning + ) { + "Starting async custom transaction..." + } else { + "Browse Products" + }, + onClick = onBrowseProducts, + ) + }, + content = { Text(helperText, style = MaterialTheme.typography.bodyMedium) }, + ) +} + @OptIn(ExperimentalComposeUiApi::class) @Composable private fun Nav2ComposeProductListRoute( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, onOpenProduct42: () -> Unit, onOpenProduct7: () -> Unit, ) { @@ -363,7 +559,7 @@ private fun Nav2ComposeProductListRoute( @Composable private fun Nav2ComposeProductDetailRoute( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, productId: String, source: String, campaign: String, @@ -372,16 +568,16 @@ private fun Nav2ComposeProductDetailRoute( onCheckout: () -> Unit, ) { LaunchedEffect(productId, source, campaign) { - recordSimulatedBackgroundSpan(Nav2RouteNames.PRODUCT_DETAIL) + recordSimulatedBackgroundSpan(RouteNames.PRODUCT_DETAIL) } Nav2ComposeActionRoute( routeSpec, arguments = mapOf( - Nav2Args.PRODUCT_ID to productId, - Nav2Args.SOURCE to source, - Nav2Args.CAMPAIGN to campaign, + NavArgs.PRODUCT_ID to productId, + NavArgs.SOURCE to source, + NavArgs.CAMPAIGN to campaign, ), buttons = listOf( @@ -394,33 +590,33 @@ private fun Nav2ComposeProductDetailRoute( @Composable private fun Nav2ComposeCheckoutRoute( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, productId: String, onCompleteOrder: () -> Unit, ) { Nav2ComposeActionRoute( routeSpec, - arguments = mapOf(Nav2Args.PRODUCT_ID to productId), + arguments = mapOf(NavArgs.PRODUCT_ID to productId), buttons = listOf("Complete Order" to onCompleteOrder), ) } @Composable private fun Nav2ComposeConfirmationRoute( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, orderId: String, onResetBackStack: () -> Unit, ) { Nav2ComposeActionRoute( routeSpec, - arguments = mapOf(Nav2Args.ORDER_ID to orderId), + arguments = mapOf(NavArgs.ORDER_ID to orderId), buttons = listOf("Reset Backstack" to onResetBackStack), ) } @Composable private fun Nav2ComposeActionRoute( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, arguments: Map = emptyMap(), buttons: List Unit>>, ) { @@ -437,7 +633,7 @@ private fun Nav2ComposeActionRoute( @Composable private fun Nav2ComposePromoDialogRoute( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, promoId: String, onCaptureException: () -> Unit, onCrashApp: () -> Unit, @@ -448,7 +644,7 @@ private fun Nav2ComposePromoDialogRoute( title = { Text(routeSpec.title) }, text = { val argumentText = - routeSpec.displayArguments(mapOf(Nav2Args.PROMO_ID to promoId)).toDisplayString() + routeSpec.displayArguments(mapOf(NavArgs.PROMO_ID to promoId)).toDisplayString() Text( listOfNotNull(routeSpec.description, argumentText.takeIf { it.isNotEmpty() }) .joinToString("\n\n") @@ -483,7 +679,7 @@ private fun Nav2ComposePromoDialogRoute( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun Nav2ComposeShareSheetRoute( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, productId: String, onCaptureException: () -> Unit, onCrashApp: () -> Unit, @@ -496,8 +692,7 @@ private fun Nav2ComposeShareSheetRoute( ) { Text(routeSpec.title, style = MaterialTheme.typography.headlineSmall) routeSpec.description?.let { Text(it) } - routeSpec.displayArguments(mapOf(Nav2Args.PRODUCT_ID to productId)).forEach { (label, value) - -> + routeSpec.displayArguments(mapOf(NavArgs.PRODUCT_ID to productId)).forEach { (label, value) -> Text("$label=$value") } Button( @@ -527,7 +722,7 @@ private fun Nav2ComposeShareSheetRoute( @Composable private fun Nav2ComposeRouteScaffold( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, cardContent: (@Composable ColumnScope.() -> Unit)? = null, content: (@Composable ColumnScope.() -> Unit)? = null, ) { @@ -602,6 +797,7 @@ private fun Nav2ComposeRouteButton( private fun nav2ComposeInteractionTag(label: String): String = "Nav2 Compose $label" private const val PRODUCT_LIST_ITEM_COUNT = 20 +private const val COMPOSE_ROUTE_TRANSITION_MILLIS = 350 @Composable private fun Nav2ComposeRouteInfo(label: String, value: String) { @@ -651,16 +847,17 @@ private fun nav2ComposeBackStackSaver() = private fun List.toComposeBackStackText(): String = joinToString(" -> ") { destination -> destination.backStackRoute() } -private sealed class Nav2ComposeDestination( +internal sealed class Nav2ComposeDestination( val routeName: String, val route: String, val arguments: Map = emptyMap(), ) { - data object Home : Nav2ComposeDestination(Nav2RouteNames.HOME, Nav2RouteNames.HOME) + data object Home : Nav2ComposeDestination(RouteNames.HOME, RouteNames.HOME) + + data object Custom : Nav2ComposeDestination(RouteNames.CUSTOM, RouteNames.CUSTOM) - data object ProductList : - Nav2ComposeDestination(Nav2RouteNames.PRODUCT_LIST, Nav2RouteNames.PRODUCT_LIST) + data object ProductList : Nav2ComposeDestination(RouteNames.PRODUCT_LIST, RouteNames.PRODUCT_LIST) data class ProductDetail( val productId: String, @@ -668,42 +865,42 @@ private sealed class Nav2ComposeDestination( val campaign: String = "", ) : Nav2ComposeDestination( - routeName = Nav2RouteNames.PRODUCT_DETAIL, + routeName = RouteNames.PRODUCT_DETAIL, route = - "${Nav2RouteNames.PRODUCT_DETAIL}/$productId/$source" + - if (campaign.isNotEmpty()) "?${Nav2Args.CAMPAIGN}=$campaign" else "", + "${RouteNames.PRODUCT_DETAIL}/$productId/$source" + + if (campaign.isNotEmpty()) "?${NavArgs.CAMPAIGN}=$campaign" else "", arguments = mapOf( - Nav2Args.PRODUCT_ID to productId, - Nav2Args.SOURCE to source, - Nav2Args.CAMPAIGN to campaign, + NavArgs.PRODUCT_ID to productId, + NavArgs.SOURCE to source, + NavArgs.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), + routeName = RouteNames.CHECKOUT, + route = "${RouteNames.CHECKOUT}/$productId", + arguments = mapOf(NavArgs.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), + routeName = RouteNames.CONFIRMATION, + route = "${RouteNames.CONFIRMATION}/$orderId", + arguments = mapOf(NavArgs.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), + routeName = RouteNames.PROMO_DIALOG, + route = "${RouteNames.PROMO_DIALOG}/$promoId", + arguments = mapOf(NavArgs.PROMO_ID to promoId), ) fun displayRoute(): String { - return Nav2RouteSpecs.get(routeName).displayRoute(arguments) + return RouteSpecs.get(routeName).displayRoute(arguments) } fun backStackRoute(): String = "/$routeName" @@ -712,59 +909,61 @@ private sealed class Nav2ComposeDestination( Bundle().apply { when (this@Nav2ComposeDestination) { Home -> putString("type", "home") + Custom -> putString("type", "custom") ProductList -> putString("type", "product_list") is ProductDetail -> { putString("type", "product_detail") - putString(Nav2Args.PRODUCT_ID, productId) - putString(Nav2Args.SOURCE, source) - putString(Nav2Args.CAMPAIGN, campaign) + putString(NavArgs.PRODUCT_ID, productId) + putString(NavArgs.SOURCE, source) + putString(NavArgs.CAMPAIGN, campaign) } is Checkout -> { putString("type", "checkout") - putString(Nav2Args.PRODUCT_ID, productId) + putString(NavArgs.PRODUCT_ID, productId) } is Confirmation -> { putString("type", "confirmation") - putString(Nav2Args.ORDER_ID, orderId) + putString(NavArgs.ORDER_ID, orderId) } is PromoDialog -> { putString("type", "promo_dialog") - putString(Nav2Args.PROMO_ID, promoId) + putString(NavArgs.PROMO_ID, promoId) } } } companion object { const val PRODUCT_DETAIL_ROUTE = - Nav2RouteNames.PRODUCT_DETAIL + + RouteNames.PRODUCT_DETAIL + "/{" + - Nav2Args.PRODUCT_ID + + NavArgs.PRODUCT_ID + "}/{" + - Nav2Args.SOURCE + + NavArgs.SOURCE + "}?" + - Nav2Args.CAMPAIGN + + NavArgs.CAMPAIGN + "={" + - Nav2Args.CAMPAIGN + + NavArgs.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 + "}" + const val CHECKOUT_ROUTE = RouteNames.CHECKOUT + "/{" + NavArgs.PRODUCT_ID + "}" + const val CONFIRMATION_ROUTE = RouteNames.CONFIRMATION + "/{" + NavArgs.ORDER_ID + "}" + const val PROMO_DIALOG_ROUTE = RouteNames.PROMO_DIALOG + "/{" + NavArgs.PROMO_ID + "}" } } private fun Bundle.toNav2ComposeDestination(): Nav2ComposeDestination { return when (getString("type")) { "home" -> Home + "custom" -> Custom "product_list" -> ProductList "product_detail" -> ProductDetail( - productId = requireNotNull(getString(Nav2Args.PRODUCT_ID)), - source = requireNotNull(getString(Nav2Args.SOURCE)), - campaign = getString(Nav2Args.CAMPAIGN).orEmpty(), + productId = requireNotNull(getString(NavArgs.PRODUCT_ID)), + source = requireNotNull(getString(NavArgs.SOURCE)), + campaign = getString(NavArgs.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))) + "checkout" -> Checkout(productId = requireNotNull(getString(NavArgs.PRODUCT_ID))) + "confirmation" -> Confirmation(orderId = requireNotNull(getString(NavArgs.ORDER_ID))) + "promo_dialog" -> PromoDialog(promoId = requireNotNull(getString(NavArgs.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/nav2/Nav2ContentHosts.kt similarity index 97% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ContentHosts.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ContentHosts.kt index 38a85b77a82..4176bcac294 100644 --- 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/nav2/Nav2ContentHosts.kt @@ -1,4 +1,4 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.nav2 import android.content.Context import android.view.View diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2CustomTransactions.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2CustomTransactions.kt new file mode 100644 index 00000000000..26cf47c2c59 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2CustomTransactions.kt @@ -0,0 +1,205 @@ +package io.sentry.samples.android.navigation.nav2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import io.sentry.ITransaction +import io.sentry.Sentry +import io.sentry.SpanStatus +import io.sentry.TransactionOptions +import io.sentry.samples.android.navigation.common.RouteNames + +internal enum class Nav2CustomTransactionMode( + val label: String, + val description: String, +) { + PER_SCREEN( + label = "Per Screen", + description = + "Starts a custom transaction for every destination so Nav2 route work runs under app-owned screen-level transactions.", + ), + WHOLE_FLOW( + label = "Whole Flow", + description = + "Keeps one custom transaction open for the whole shopping journey until the flow returns to the Custom home screen.", + ), + LINGERING( + label = "Lingering", + description = + "Starts one custom transaction and intentionally leaves it active across later routes to simulate a stale power-user transaction.", + ), + ASYNC_FROM_USER_ACTION( + label = "Async From User Action", + description = + "Starts a custom transaction from the Browse Products button, waits for async work, then navigates to Product List while the manual transaction stays active.", + ), +} + +@Composable +internal fun Nav2CustomTransactionEffect( + selectedDestination: Any, + mode: Nav2CustomTransactionMode, + controller: Nav2CustomTransactionController, +) { + DisposableEffect(selectedDestination, mode) { + controller.onDestinationChanged(selectedDestination, mode) + onDispose {} + } + + DisposableEffect(controller) { onDispose { controller.cleanup() } } +} + +internal class Nav2CustomTransactionController { + + private var activeTransaction: ITransaction? = null + private var activeMode: Nav2CustomTransactionMode? = null + private var activeRouteName: String? = null + + fun onDestinationChanged(destination: Any, mode: Nav2CustomTransactionMode) { + val currentRouteName = + when (destination) { + is Nav2ComposeDestination -> destination.routeName + else -> return cleanup() + } + + val isCustomScenario = destination is Nav2ComposeDestination.Custom || activeMode != null + if (!isCustomScenario) { + cleanup() + return + } + + when (mode) { + Nav2CustomTransactionMode.PER_SCREEN -> handlePerScreen(currentRouteName) + Nav2CustomTransactionMode.WHOLE_FLOW -> handleWholeFlow(currentRouteName) + Nav2CustomTransactionMode.LINGERING -> handleLingering(currentRouteName) + Nav2CustomTransactionMode.ASYNC_FROM_USER_ACTION -> + handleAsyncFromUserAction(currentRouteName) + } + } + + fun startAsyncBrowseProductsTransaction() { + finishActiveTransaction() + activeTransaction = + startCustomTransaction( + name = "custom.tap_to_browse_products", + operation = "ui.action", + mode = Nav2CustomTransactionMode.ASYNC_FROM_USER_ACTION, + routeName = RouteNames.CUSTOM, + ) + activeMode = Nav2CustomTransactionMode.ASYNC_FROM_USER_ACTION + activeRouteName = RouteNames.CUSTOM + activeTransaction?.setData("sample.async_trigger", "browse_products") + } + + fun cleanup() { + finishActiveTransaction() + } + + private fun handlePerScreen(currentRoute: String) { + if (activeMode == Nav2CustomTransactionMode.PER_SCREEN && activeRouteName == currentRoute) { + return + } + + finishActiveTransaction() + activeTransaction = + startCustomTransaction( + name = "custom.${currentRoute.lowercase()}_screen", + operation = "ui.screen.manual", + mode = Nav2CustomTransactionMode.PER_SCREEN, + routeName = currentRoute, + ) + activeMode = Nav2CustomTransactionMode.PER_SCREEN + activeRouteName = currentRoute + } + + private fun handleWholeFlow(currentRoute: String) { + if (currentRoute == RouteNames.CUSTOM) { + if (activeMode == Nav2CustomTransactionMode.WHOLE_FLOW) { + finishActiveTransaction() + } + return + } + + if (activeMode != Nav2CustomTransactionMode.WHOLE_FLOW || activeTransaction == null) { + finishActiveTransaction() + activeTransaction = + startCustomTransaction( + name = "custom.checkout_flow", + operation = "ui.flow.manual", + mode = Nav2CustomTransactionMode.WHOLE_FLOW, + routeName = currentRoute, + ) + activeMode = Nav2CustomTransactionMode.WHOLE_FLOW + } + + activeRouteName = currentRoute + activeTransaction?.setData("sample.current_route", currentRoute) + } + + private fun handleLingering(currentRoute: String) { + if (activeMode == Nav2CustomTransactionMode.LINGERING && activeTransaction != null) { + activeTransaction?.setData("sample.current_route", currentRoute) + return + } + + finishActiveTransaction() + activeTransaction = + startCustomTransaction( + name = "custom.lingering_navigation_transaction", + operation = "ui.flow.manual", + mode = Nav2CustomTransactionMode.LINGERING, + routeName = currentRoute, + ) + activeMode = Nav2CustomTransactionMode.LINGERING + activeRouteName = currentRoute + } + + private fun handleAsyncFromUserAction(currentRoute: String) { + if (activeMode != Nav2CustomTransactionMode.ASYNC_FROM_USER_ACTION) { + finishActiveTransaction() + return + } + + val transaction = activeTransaction ?: return + activeRouteName = currentRoute + transaction.setData("sample.current_route", currentRoute) + + if (currentRoute != RouteNames.CUSTOM && currentRoute != RouteNames.PRODUCT_LIST) { + finishActiveTransaction() + } + } + + private fun startCustomTransaction( + name: String, + operation: String, + mode: Nav2CustomTransactionMode, + routeName: String, + ): ITransaction { + val options = + TransactionOptions().also { + it.isBindToScope = true + it.isWaitForChildren = true + it.idleTimeout = null + } + return Sentry.startTransaction(name, operation, options).apply { + setTag("sample_nav2_scenario", Nav2Scenario.CUSTOM.label) + setTag("sample_nav2_custom_mode", mode.label) + setData("sample.custom_transaction", true) + setData("sample.current_route", routeName) + } + } + + private fun finishActiveTransaction() { + val transaction = activeTransaction ?: return + transaction.finish(transaction.status ?: SpanStatus.OK) + Sentry.configureScope { scope -> + scope.withTransaction { current -> + if (current == transaction) { + scope.clearTransaction() + } + } + } + activeTransaction = null + activeMode = null + activeRouteName = null + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ModalFragments.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ModalFragments.kt similarity index 91% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ModalFragments.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ModalFragments.kt index a06df9ae62f..dc1e593a68b 100644 --- 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/nav2/Nav2ModalFragments.kt @@ -1,4 +1,4 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.nav2 import android.app.Dialog import android.content.Context @@ -16,13 +16,17 @@ import android.widget.TextView import androidx.core.view.setPadding import androidx.fragment.app.DialogFragment import io.sentry.samples.android.R +import io.sentry.samples.android.navigation.common.NavArgs +import io.sentry.samples.android.navigation.common.RouteSpecs +import io.sentry.samples.android.navigation.common.displayArguments +import io.sentry.samples.android.navigation.common.themeColor class Nav2PromoDialogFragment : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val activity = requireActivity() as Nav2Activity activity.tagCurrentScenarioOnTransaction() - val promoId = requireArguments().getString(Nav2Args.PROMO_ID).orEmpty() + val promoId = requireArguments().getString(NavArgs.PROMO_ID).orEmpty() return Dialog(requireContext()).apply { window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) @@ -43,14 +47,14 @@ class Nav2PromoDialogFragment : DialogFragment() { private fun promoDialogContent(activity: Nav2Activity, promoId: String): View = LinearLayout(requireContext()).apply { - val routeSpec = Nav2RouteSpecs.promoDialog + val routeSpec = RouteSpecs.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 { + routeSpec.displayArguments(mapOf(NavArgs.PROMO_ID to promoId)).firstOrNull()?.let { (label, value) -> addView(argumentPill(context, "$label=$value")) } @@ -85,7 +89,7 @@ class Nav2ShareSheetFragment : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val activity = requireActivity() as Nav2Activity activity.tagCurrentScenarioOnTransaction() - val productId = requireArguments().getString(Nav2Args.PRODUCT_ID).orEmpty() + val productId = requireArguments().getString(NavArgs.PRODUCT_ID).orEmpty() return Dialog(requireContext()).apply { window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) @@ -109,14 +113,14 @@ class Nav2ShareSheetFragment : DialogFragment() { private fun shareSheetContent(activity: Nav2Activity, productId: String): View = LinearLayout(requireContext()).apply { - val routeSpec = Nav2RouteSpecs.shareSheet + val routeSpec = RouteSpecs.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 { + routeSpec.displayArguments(mapOf(NavArgs.PRODUCT_ID to productId)).firstOrNull()?.let { (label, value) -> addView(argumentPill(context, "$label=$value")) } 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/nav2/Nav2PerformanceControls.kt similarity index 94% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/NavigationPerformanceControls.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2PerformanceControls.kt index 54ae586566d..2dc9a4517e4 100644 --- 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/nav2/Nav2PerformanceControls.kt @@ -1,4 +1,4 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.nav2 import android.os.Trace import androidx.compose.foundation.layout.Arrangement @@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay /** State holder backing the [Nav2Scenario.PERFORMANCE] tab. */ -internal class NavigationPerformanceState { +internal class Nav2PerformanceState { var stackDepth by mutableIntStateOf(30) var autoRecompose by mutableStateOf(false) @@ -83,12 +83,12 @@ internal class NavigationPerformanceState { /** "Performance" tab content. */ @Composable -internal fun NavigationPerformancePanel( +internal fun Nav2PerformancePanel( title: String, description: String, currentRoute: String, backStack: String, - state: NavigationPerformanceState, + state: Nav2PerformanceState, onBuildStack: () -> Unit, onReplaceTop: () -> Unit, ) { @@ -98,7 +98,7 @@ internal fun NavigationPerformancePanel( if (!state.autoRecompose) { break } - traceNavigationPerformanceSection("Nav2Stress.autoRecompose") { + traceNav2PerformanceSection("Nav2Stress.autoRecompose") { state.markRecompositionRequest() } } @@ -110,7 +110,7 @@ internal fun NavigationPerformancePanel( if (!state.autoNavigate) { break } - traceNavigationPerformanceSection("Nav2Stress.autoNavigate") { onReplaceTop() } + traceNav2PerformanceSection("Nav2Stress.autoNavigate") { onReplaceTop() } } } @@ -264,7 +264,7 @@ private fun PerfToggleButton( } } -internal fun traceNavigationPerformanceSection(sectionName: String, block: () -> Unit) { +internal fun traceNav2PerformanceSection(sectionName: String, block: () -> Unit) { Trace.beginSection(sectionName) try { block() @@ -273,7 +273,7 @@ internal fun traceNavigationPerformanceSection(sectionName: String, block: () -> } } -internal fun navigationPerformanceBackStackPreview(entries: List): String { +internal fun nav2PerformanceBackStackPreview(entries: List): String { if (entries.size <= 8) { return entries.joinToString(" -> ") } 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/nav2/Nav2RouteFragment.kt similarity index 86% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteFragment.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2RouteFragment.kt index 5fb4ef1bdd6..8a76d3eb301 100644 --- 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/nav2/Nav2RouteFragment.kt @@ -1,4 +1,4 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.nav2 import android.graphics.Typeface import android.os.Bundle @@ -16,6 +16,12 @@ import androidx.lifecycle.lifecycleScope import io.sentry.ISpan import io.sentry.Sentry import io.sentry.samples.android.R +import io.sentry.samples.android.navigation.common.NavArgs +import io.sentry.samples.android.navigation.common.RouteNames +import io.sentry.samples.android.navigation.common.RouteSpec +import io.sentry.samples.android.navigation.common.RouteSpecs +import io.sentry.samples.android.navigation.common.displayArguments +import io.sentry.samples.android.navigation.common.themeColor import kotlinx.coroutines.launch class Nav2RouteFragment : Fragment() { @@ -26,13 +32,13 @@ class Nav2RouteFragment : Fragment() { savedInstanceState: Bundle?, ): View { val activity = requireActivity() as Nav2Activity - val routeName = requireArguments().getString(Nav2Args.ROUTE_NAME).orEmpty() - val routeSpec = Nav2RouteSpecs.get(routeName) + val routeName = requireArguments().getString(NavArgs.ROUTE_NAME).orEmpty() + val routeSpec = RouteSpecs.get(routeName) return when (routeName) { - Nav2RouteNames.LANDING -> routeLayout(routeSpec) + RouteNames.LANDING -> routeLayout(routeSpec) - Nav2RouteNames.HOME -> + RouteNames.HOME -> routeLayout( routeSpec, buttons = @@ -43,7 +49,7 @@ class Nav2RouteFragment : Fragment() { ), ) - Nav2RouteNames.DEEP_LINK -> + RouteNames.DEEP_LINK -> routeLayout( routeSpec, buttons = @@ -54,7 +60,7 @@ class Nav2RouteFragment : Fragment() { ), ) - Nav2RouteNames.PRODUCT_LIST -> + RouteNames.PRODUCT_LIST -> routeLayout( routeSpec, buttons = @@ -71,15 +77,15 @@ class Nav2RouteFragment : Fragment() { trailingContent = { addProductListItemsToggle() }, ) - Nav2RouteNames.PRODUCT_DETAIL -> productDetailLayout(activity) + RouteNames.PRODUCT_DETAIL -> productDetailLayout(activity) - Nav2RouteNames.CHECKOUT -> checkoutLayout(activity) + RouteNames.CHECKOUT -> checkoutLayout(activity) - Nav2RouteNames.CONFIRMATION -> confirmationLayout(activity) + RouteNames.CONFIRMATION -> confirmationLayout(activity) else -> routeLayout( - Nav2RouteSpec(routeName = routeName, title = "Unknown Route", description = routeName) + RouteSpec(routeName = routeName, title = "Unknown Route", description = routeName) ) } } @@ -87,16 +93,16 @@ class Nav2RouteFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val activity = requireActivity() as Nav2Activity - val routeName = requireArguments().getString(Nav2Args.ROUTE_NAME).orEmpty() + val routeName = requireArguments().getString(NavArgs.ROUTE_NAME).orEmpty() activity.tagCurrentScenarioOnTransaction() - if (routeName == Nav2RouteNames.LANDING) { + if (routeName == RouteNames.LANDING) { view.post { activity.cancelCurrentUiLoadTransaction() } return } - if (routeName == Nav2RouteNames.PRODUCT_DETAIL) { + if (routeName == RouteNames.PRODUCT_DETAIL) { viewLifecycleOwner.lifecycleScope.launch { recordSimulatedBackgroundSpan(routeName) } @@ -107,8 +113,8 @@ class Nav2RouteFragment : Fragment() { 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 productId = arguments.getString(NavArgs.PRODUCT_ID).orEmpty() + val source = arguments.getString(NavArgs.SOURCE).orEmpty() val scenario = if (source == "deep-link") { Nav2Scenario.DEEP_LINK @@ -116,7 +122,7 @@ class Nav2RouteFragment : Fragment() { Nav2Scenario.FRAGMENTS } - val routeSpec = Nav2RouteSpecs.productDetail + val routeSpec = RouteSpecs.productDetail return routeLayout( routeSpec, arguments = arguments, @@ -136,8 +142,8 @@ class Nav2RouteFragment : Fragment() { } private fun checkoutLayout(activity: Nav2Activity): View { - val productId = requireArguments().getString(Nav2Args.PRODUCT_ID).orEmpty() - val routeSpec = Nav2RouteSpecs.checkout + val productId = requireArguments().getString(NavArgs.PRODUCT_ID).orEmpty() + val routeSpec = RouteSpecs.checkout return routeLayout( routeSpec, arguments = requireArguments(), @@ -151,7 +157,7 @@ class Nav2RouteFragment : Fragment() { } private fun confirmationLayout(activity: Nav2Activity): View { - val routeSpec = Nav2RouteSpecs.confirmation + val routeSpec = RouteSpecs.confirmation return routeLayout( routeSpec, arguments = requireArguments(), @@ -165,7 +171,7 @@ class Nav2RouteFragment : Fragment() { } private fun routeLayout( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, arguments: Bundle? = null, buttons: List = emptyList(), trailingContent: (LinearLayout.() -> Unit)? = null, diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Routes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Routes.kt new file mode 100644 index 00000000000..7cf3532a09d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Routes.kt @@ -0,0 +1,217 @@ +package io.sentry.samples.android.navigation.nav2 + +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 io.sentry.samples.android.navigation.common.NavArgs +import io.sentry.samples.android.navigation.common.RouteNames +import io.sentry.samples.android.navigation.common.RouteSpec +import io.sentry.samples.android.navigation.common.RouteSpecs +import io.sentry.samples.android.navigation.common.displayRoute +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine + +internal sealed class Nav2Destination( + val id: Int, + val routeName: String, + val arguments: Bundle = Bundle.EMPTY, +) { + + data object Landing : Nav2Destination(R.id.nav2_landing, RouteNames.LANDING) + + data object Home : Nav2Destination(R.id.nav2_home, RouteNames.HOME) + + data object ProductList : Nav2Destination(R.id.nav2_product_list, RouteNames.PRODUCT_LIST) + + data object DeepLink : Nav2Destination(R.id.nav2_deep_link, RouteNames.DEEP_LINK) + + data class ProductDetail( + val productId: String, + val source: String, + val campaign: String = "", + ) : + Nav2Destination( + R.id.nav2_product_detail, + RouteNames.PRODUCT_DETAIL, + bundleOf( + NavArgs.PRODUCT_ID to productId, + NavArgs.SOURCE to source, + NavArgs.CAMPAIGN to campaign, + ), + ) + + data class Checkout(val productId: String) : + Nav2Destination( + R.id.nav2_checkout, + RouteNames.CHECKOUT, + bundleOf(NavArgs.PRODUCT_ID to productId), + ) + + data class Confirmation(val orderId: String) : + Nav2Destination( + R.id.nav2_confirmation, + RouteNames.CONFIRMATION, + bundleOf(NavArgs.ORDER_ID to orderId), + ) + + data class PromoDialog(val promoId: String, val scenario: Nav2Scenario) : + Nav2Destination( + R.id.nav2_promo_dialog, + RouteNames.PROMO_DIALOG, + bundleOf(NavArgs.PROMO_ID to promoId, NavArgs.SCENARIO to scenario.name), + ) + + data class ShareSheet(val productId: String, val scenario: Nav2Scenario) : + Nav2Destination( + R.id.nav2_share_sheet, + RouteNames.SHARE_SHEET, + bundleOf(NavArgs.PRODUCT_ID to productId, NavArgs.SCENARIO to scenario.name), + ) +} + +internal enum class Nav2Scenario(val label: String, val showTab: Boolean = true) { + LANDING(RouteNames.LANDING, showTab = false), + COMPOSE("Compose"), + FRAGMENTS("Fragments"), + DEEP_LINK("Deep Link (Fragments)"), + CUSTOM("Custom"), + PERFORMANCE("Performance"), +} + +internal fun Nav2Destination.routeSpec(): RouteSpec = RouteSpecs.get(routeName) + +internal fun Nav2Destination.displayRoute(): String = routeSpec().displayRoute(arguments) + +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(NavArgs.PRODUCT_ID) == productId && + arguments.getString(NavArgs.SOURCE) == source && + arguments.getString(NavArgs.CAMPAIGN).orEmpty() == campaign + is Nav2Destination.Checkout -> arguments?.getString(NavArgs.PRODUCT_ID) == productId + is Nav2Destination.Confirmation -> arguments?.getString(NavArgs.ORDER_ID) == orderId + is Nav2Destination.PromoDialog -> arguments?.getString(NavArgs.PROMO_ID) == promoId + is Nav2Destination.ShareSheet -> arguments?.getString(NavArgs.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(NavArgs.PRODUCT_ID).orEmpty(), + source = arguments?.getString(NavArgs.SOURCE).orEmpty(), + campaign = arguments?.getString(NavArgs.CAMPAIGN).orEmpty(), + ) + R.id.nav2_checkout -> + Nav2Destination.Checkout(arguments?.getString(NavArgs.PRODUCT_ID).orEmpty()) + R.id.nav2_confirmation -> + Nav2Destination.Confirmation(arguments?.getString(NavArgs.ORDER_ID).orEmpty()) + R.id.nav2_promo_dialog -> + Nav2Destination.PromoDialog( + promoId = arguments?.getString(NavArgs.PROMO_ID).orEmpty(), + scenario = + arguments?.getString(NavArgs.SCENARIO).orEmpty().toNav2Scenario() + ?: Nav2Scenario.FRAGMENTS, + ) + R.id.nav2_share_sheet -> + Nav2Destination.ShareSheet( + productId = arguments?.getString(NavArgs.PRODUCT_ID).orEmpty(), + scenario = + arguments?.getString(NavArgs.SCENARIO).orEmpty().toNav2Scenario() + ?: Nav2Scenario.FRAGMENTS, + ) + else -> null + } + +internal fun NavDestination.routeName(): String = routeNameOrNull() ?: RouteNames.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/nav2/Nav2SampleConfigAdapter.kt similarity index 57% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SampleConfig.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2SampleConfigAdapter.kt index 064deeb0895..13b97107e3f 100644 --- 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/nav2/Nav2SampleConfigAdapter.kt @@ -1,70 +1,19 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.nav2 import android.content.Context import android.content.Intent -import io.sentry.Sentry +import android.os.Bundle +import io.sentry.samples.android.navigation.common.NavigationSampleConfig +import io.sentry.samples.android.navigation.common.NavigationSampleConfigSnapshot +import io.sentry.samples.android.navigation.common.currentNavigationSampleConfigSnapshot -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 currentNav2SampleConfigSnapshot(): NavigationSampleConfigSnapshot = + currentNavigationSampleConfigSnapshot() internal fun Intent.previousNav2SampleConfigSnapshot( - fallback: Nav2SampleConfigSnapshot -): Nav2SampleConfigSnapshot = - Nav2SampleConfigSnapshot( + fallback: NavigationSampleConfigSnapshot +): NavigationSampleConfigSnapshot = + NavigationSampleConfigSnapshot( enableScreenTracking = getBooleanExtra(EXTRA_PREVIOUS_ENABLE_SCREEN_TRACKING, fallback.enableScreenTracking), enableUserInteractionTransactions = @@ -79,8 +28,8 @@ internal fun Intent.previousNav2SampleConfigSnapshot( ), ) -internal fun Intent.nav2SampleConfig(): Nav2SampleConfig = - Nav2SampleConfig( +internal fun Intent.nav2SampleConfig(): NavigationSampleConfig = + NavigationSampleConfig( enableNavigationTransactions = getBooleanExtra(EXTRA_ENABLE_NAVIGATION_TRANSACTIONS, true), enableNavigationBreadcrumbs = getBooleanExtra(EXTRA_ENABLE_NAVIGATION_BREADCRUMBS, true), enableScreenTracking = getBooleanExtra(EXTRA_ENABLE_SCREEN_TRACKING, true), @@ -93,8 +42,8 @@ internal fun Intent.nav2SampleConfig(): Nav2SampleConfig = ) internal fun Context.nav2LaunchIntent( - configuration: Nav2SampleConfig, - previousOptions: Nav2SampleConfigSnapshot, + configuration: NavigationSampleConfig, + previousOptions: NavigationSampleConfigSnapshot, ): Intent = Intent(this, Nav2Activity::class.java) .putExtra(EXTRA_ENABLE_NAVIGATION_TRANSACTIONS, configuration.enableNavigationTransactions) @@ -122,6 +71,35 @@ internal fun Context.nav2LaunchIntent( previousOptions.enableUserInteractionBreadcrumbs, ) +internal fun Bundle.putNav2SampleConfiguration(configuration: NavigationSampleConfig) { + putBoolean(STATE_ENABLE_NAVIGATION_TRANSACTIONS, configuration.enableNavigationTransactions) + putBoolean(STATE_ENABLE_NAVIGATION_BREADCRUMBS, configuration.enableNavigationBreadcrumbs) + putBoolean(STATE_ENABLE_SCREEN_TRACKING, configuration.enableScreenTracking) + putBoolean( + STATE_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION, + configuration.enableActivityUiLoadTransaction, + ) + putBoolean( + STATE_ENABLE_USER_INTERACTION_TRANSACTIONS, + configuration.enableUserInteractionTransactions, + ) + putBoolean( + STATE_ENABLE_USER_INTERACTION_BREADCRUMBS, + configuration.enableUserInteractionBreadcrumbs, + ) +} + +internal fun Bundle.nav2SampleConfiguration(): NavigationSampleConfig = + NavigationSampleConfig( + enableNavigationTransactions = getBoolean(STATE_ENABLE_NAVIGATION_TRANSACTIONS, true), + enableNavigationBreadcrumbs = getBoolean(STATE_ENABLE_NAVIGATION_BREADCRUMBS, true), + enableScreenTracking = getBoolean(STATE_ENABLE_SCREEN_TRACKING, true), + enableActivityUiLoadTransaction = getBoolean(STATE_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION, false), + enableUserInteractionTransactions = + getBoolean(STATE_ENABLE_USER_INTERACTION_TRANSACTIONS, false), + enableUserInteractionBreadcrumbs = getBoolean(STATE_ENABLE_USER_INTERACTION_BREADCRUMBS, false), + ) + private const val EXTRA_ENABLE_NAVIGATION_TRANSACTIONS = "io.sentry.samples.android.navigation.enable_navigation_transactions" private const val EXTRA_ENABLE_NAVIGATION_BREADCRUMBS = @@ -140,3 +118,10 @@ 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" +private const val STATE_ENABLE_NAVIGATION_TRANSACTIONS = "enable_navigation_transactions" +private const val STATE_ENABLE_NAVIGATION_BREADCRUMBS = "enable_navigation_breadcrumbs" +private const val STATE_ENABLE_SCREEN_TRACKING = "enable_screen_tracking" +private const val STATE_ENABLE_ACTIVITY_UI_LOAD_TRANSACTION = "enable_activity_ui_load_transaction" +private const val STATE_ENABLE_USER_INTERACTION_TRANSACTIONS = + "enable_user_interaction_transactions" +private const val STATE_ENABLE_USER_INTERACTION_BREADCRUMBS = "enable_user_interaction_breadcrumbs" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SampleTheme.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2SampleTheme.kt similarity index 95% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SampleTheme.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2SampleTheme.kt index f6d18767343..b174476deb9 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2SampleTheme.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2SampleTheme.kt @@ -1,4 +1,4 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.nav2 import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2SetupActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2SetupActivity.kt new file mode 100644 index 00000000000..d982786d49c --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2SetupActivity.kt @@ -0,0 +1,44 @@ +package io.sentry.samples.android.navigation.nav2 + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.sentry.samples.android.navigation.common.NavigationSampleConfig +import io.sentry.samples.android.navigation.common.NavigationSetupScreen +import io.sentry.samples.android.navigation.common.applyToCurrentOptions + +/** Activity for configuring the developer's experience in the [Nav2Activity]. */ +class Nav2SetupActivity : AppCompatActivity() { + + private var configuration by mutableStateOf(NavigationSampleConfig()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + configuration = savedInstanceState?.nav2SampleConfiguration() ?: configuration + setContent { + Nav2SampleTheme { + NavigationSetupScreen( + navName = "Nav2", + navVersion = "2", + 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) + } +} 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/nav2/Nav2TopBar.kt similarity index 89% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TopBar.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2TopBar.kt index c21cebf6b39..6cacd2959ab 100644 --- 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/nav2/Nav2TopBar.kt @@ -1,4 +1,4 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.nav2 import android.content.Context import android.graphics.Typeface @@ -19,6 +19,9 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.setPadding import io.sentry.samples.android.R +import io.sentry.samples.android.navigation.common.RouteNames +import io.sentry.samples.android.navigation.common.RouteWorkOption +import io.sentry.samples.android.navigation.common.themeColor /** * A top bar consisting of nav info above tabs for selecting among a variety of [Nav2Scenario]s. @@ -37,28 +40,33 @@ internal class Nav2TopBar( mutableMapOf( Nav2Scenario.LANDING to Nav2TopBarState( - currentRoute = "/${Nav2RouteNames.LANDING}", - backStack = "/${Nav2RouteNames.LANDING}", + currentRoute = "/${RouteNames.LANDING}", + backStack = "/${RouteNames.LANDING}", ), Nav2Scenario.COMPOSE to Nav2TopBarState( - currentRoute = "/${Nav2RouteNames.HOME}", - backStack = "/${Nav2RouteNames.HOME}", + currentRoute = "/${RouteNames.HOME}", + backStack = "/${RouteNames.HOME}", ), Nav2Scenario.FRAGMENTS to Nav2TopBarState( - currentRoute = "/${Nav2RouteNames.HOME}", - backStack = "/${Nav2RouteNames.HOME}", + currentRoute = "/${RouteNames.HOME}", + backStack = "/${RouteNames.HOME}", ), Nav2Scenario.DEEP_LINK to Nav2TopBarState( - currentRoute = "/${Nav2RouteNames.DEEP_LINK}", - backStack = "/${Nav2RouteNames.DEEP_LINK}", + currentRoute = "/${RouteNames.DEEP_LINK}", + backStack = "/${RouteNames.DEEP_LINK}", + ), + Nav2Scenario.CUSTOM to + Nav2TopBarState( + currentRoute = "/${RouteNames.CUSTOM}", + backStack = "/${RouteNames.CUSTOM}", ), Nav2Scenario.PERFORMANCE to Nav2TopBarState( - currentRoute = "/${Nav2RouteNames.HOME}", - backStack = "/${Nav2RouteNames.HOME}", + currentRoute = "/${RouteNames.HOME}", + backStack = "/${RouteNames.HOME}", ), ) private val tabViews = mutableMapOf() @@ -183,6 +191,7 @@ internal class Nav2TopBar( Nav2Scenario.COMPOSE -> R.id.nav2_tab_compose Nav2Scenario.FRAGMENTS -> R.id.nav2_tab_fragments Nav2Scenario.DEEP_LINK -> R.id.nav2_tab_deep_link + Nav2Scenario.CUSTOM -> R.id.nav2_tab_custom Nav2Scenario.PERFORMANCE -> R.id.nav2_tab_performance } 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/nav2/Nav2TransactionHistory.kt similarity index 99% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TransactionHistory.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2TransactionHistory.kt index 1a911a35285..a846e51b8e9 100644 --- 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/nav2/Nav2TransactionHistory.kt @@ -1,4 +1,4 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.nav2 import android.os.Handler import android.os.Looper 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/nav2/Nav2TransactionHistorySheet.kt similarity index 99% rename from sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2TransactionHistorySheet.kt rename to sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2TransactionHistorySheet.kt index 657b52dd31f..878f7b5f6b5 100644 --- 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/nav2/Nav2TransactionHistorySheet.kt @@ -1,4 +1,4 @@ -package io.sentry.samples.android.navigation +package io.sentry.samples.android.navigation.nav2 import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll 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 index 142f7e97a46..8470d68c18c 100644 --- 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 @@ -8,7 +8,7 @@ + From ae0422969ecf50472c6875a68944e1d3f7676623 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 5 Oct 2026 09:48:31 +0200 Subject: [PATCH 2/4] Address SentryBot, etc. comments --- .../android/navigation/nav2/Nav2Activity.kt | 26 ++++++++++----- .../navigation/nav2/Nav2ComposeRoutes.kt | 33 +++++++++++-------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Activity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Activity.kt index 20a504a40b4..ca353d202ec 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Activity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Activity.kt @@ -212,8 +212,8 @@ class Nav2Activity : AppCompatActivity() { onCaptureException = { captureSampleException("Nav2") }, onCrashApp = { showCrashConfirmation("Nav2") }, selectedScenario = composeScenario.value, - onRouteChanged = { _, currentRoute, backStack -> - updateComposeNavigationUi(currentRoute, backStack) + onRouteChanged = { scenario, _, currentRoute, backStack -> + updateComposeNavigationUi(scenario, currentRoute, backStack) }, onExitRoot = { finish() }, ) @@ -347,11 +347,19 @@ class Nav2Activity : AppCompatActivity() { } Nav2Scenario.COMPOSE -> { contentHosts.showCompose() - updateComposeNavigationUi("/${RouteNames.HOME}", "/${RouteNames.HOME}") + updateComposeNavigationUi( + scenario = Nav2Scenario.COMPOSE, + currentRoute = "/${RouteNames.HOME}", + backStack = "/${RouteNames.HOME}", + ) } Nav2Scenario.CUSTOM -> { contentHosts.showCompose() - updateComposeNavigationUi("/${RouteNames.CUSTOM}", "/${RouteNames.CUSTOM}") + updateComposeNavigationUi( + scenario = Nav2Scenario.CUSTOM, + currentRoute = "/${RouteNames.CUSTOM}", + backStack = "/${RouteNames.CUSTOM}", + ) } Nav2Scenario.FRAGMENTS -> { contentHosts.showFragments() @@ -488,11 +496,13 @@ class Nav2Activity : AppCompatActivity() { } } - private fun updateComposeNavigationUi(currentRoute: String, backStack: String) { + private fun updateComposeNavigationUi( + scenario: Nav2Scenario, + currentRoute: String, + backStack: String, + ) { topBar.update( - scenario = - if (currentRoute.startsWith("/${RouteNames.CUSTOM}")) Nav2Scenario.CUSTOM - else Nav2Scenario.COMPOSE, + scenario = scenario, currentRoute = currentRoute, backStack = backStack, ) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ComposeRoutes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ComposeRoutes.kt index bb03639a7ee..249cee35659 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ComposeRoutes.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ComposeRoutes.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable @@ -94,7 +95,8 @@ internal fun Nav2ComposeApp( onCaptureException: () -> Unit, onCrashApp: () -> Unit, selectedScenario: Nav2Scenario, - onRouteChanged: (routeName: String, currentRoute: String, backStack: String) -> Unit, + onRouteChanged: + (scenario: Nav2Scenario, routeName: String, currentRoute: String, backStack: String) -> Unit, onExitRoot: () -> Unit, ) { @@ -105,7 +107,7 @@ internal fun Nav2ComposeApp( var customTransactionMode by rememberSaveable { mutableStateOf(Nav2CustomTransactionMode.PER_SCREEN) } - var asyncBrowseProductsJob by rememberSaveable { mutableStateOf(null) } + var asyncBrowseProductsJob by remember { mutableStateOf(null) } var isAsyncBrowseProductsRunning by rememberSaveable { mutableStateOf(false) } val customTransactionsScope = androidx.compose.runtime.rememberCoroutineScope() val customTransactionController = @@ -147,7 +149,7 @@ internal fun Nav2ComposeApp( backStack.resetTo(Home) shareSheetProductId.value = null navController.navigate(Home.route) { - popUpTo(Home.route) { inclusive = true } + popUpTo(Home.route) { inclusive = false } launchSingleTop = true } } @@ -155,7 +157,7 @@ internal fun Nav2ComposeApp( backStack.resetTo(Custom) shareSheetProductId.value = null navController.navigate(Custom.route) { - popUpTo(Home.route) { inclusive = true } + popUpTo(Home.route) { inclusive = false } launchSingleTop = true } } @@ -176,6 +178,7 @@ internal fun Nav2ComposeApp( LaunchedEffect(currentDestination, backStack.size) { onRouteChanged( + selectedScenario, currentDestination.routeName, currentDestination.displayRoute(), backStack.toComposeBackStackText(), @@ -205,13 +208,13 @@ internal fun Nav2ComposeApp( popExitTransition = { fadeOut(animationSpec = tween(COMPOSE_ROUTE_TRANSITION_MILLIS)) }, ) { composable(Home.route) { - TracedNav2ComposeRoute(Home.routeName) { + TracedNav2ComposeRoute(selectedScenario, Home.routeName) { Nav2ComposeHomeRoute(routeSpec = RouteSpecs.home) { navigateTo(ProductList) } } } composable(Custom.route) { - TracedNav2ComposeRoute(Custom.routeName) { + TracedNav2ComposeRoute(selectedScenario, Custom.routeName) { Nav2ComposeCustomRoute( routeSpec = RouteSpecs.custom, mode = customTransactionMode, @@ -248,7 +251,7 @@ internal fun Nav2ComposeApp( } composable(ProductList.route) { - TracedNav2ComposeRoute(ProductList.routeName) { + TracedNav2ComposeRoute(selectedScenario, ProductList.routeName) { Nav2ComposeProductListRoute( routeSpec = RouteSpecs.productList, onOpenProduct42 = { @@ -282,7 +285,7 @@ internal fun Nav2ComposeApp( val productId = entry.arguments?.getString(NavArgs.PRODUCT_ID).orEmpty() val source = entry.arguments?.getString(NavArgs.SOURCE).orEmpty() val campaign = entry.arguments?.getString(NavArgs.CAMPAIGN).orEmpty() - TracedNav2ComposeRoute(RouteNames.PRODUCT_DETAIL) { + TracedNav2ComposeRoute(selectedScenario, RouteNames.PRODUCT_DETAIL) { Nav2ComposeProductDetailRoute( routeSpec = RouteSpecs.productDetail, productId = productId, @@ -302,7 +305,7 @@ internal fun Nav2ComposeApp( arguments = listOf(navArgument(NavArgs.PRODUCT_ID) { type = NavType.StringType }), ) { entry -> val productId = entry.arguments?.getString(NavArgs.PRODUCT_ID).orEmpty() - TracedNav2ComposeRoute(RouteNames.CHECKOUT) { + TracedNav2ComposeRoute(selectedScenario, RouteNames.CHECKOUT) { Nav2ComposeCheckoutRoute( routeSpec = RouteSpecs.checkout, productId = productId, @@ -317,7 +320,7 @@ internal fun Nav2ComposeApp( route = Nav2ComposeDestination.CONFIRMATION_ROUTE, arguments = listOf(navArgument(NavArgs.ORDER_ID) { type = NavType.StringType }), ) { entry -> - TracedNav2ComposeRoute(RouteNames.CONFIRMATION) { + TracedNav2ComposeRoute(selectedScenario, RouteNames.CONFIRMATION) { Nav2ComposeConfirmationRoute( routeSpec = RouteSpecs.confirmation, orderId = entry.arguments?.getString(NavArgs.ORDER_ID).orEmpty(), @@ -333,7 +336,7 @@ internal fun Nav2ComposeApp( // 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(RouteNames.PROMO_DIALOG) { + TracedNav2ComposeRoute(selectedScenario, RouteNames.PROMO_DIALOG) { Nav2ComposePromoDialogRoute( routeSpec = RouteSpecs.promoDialog, promoId = entry.arguments?.getString(NavArgs.PROMO_ID).orEmpty(), @@ -378,8 +381,12 @@ internal fun Nav2ComposeApp( @OptIn(ExperimentalComposeUiApi::class) @Composable -private fun TracedNav2ComposeRoute(routeName: String, content: @Composable BoxScope.() -> Unit) { - tagCurrentNav2Scenario(Nav2Scenario.COMPOSE) +private fun TracedNav2ComposeRoute( + scenario: Nav2Scenario, + routeName: String, + content: @Composable BoxScope.() -> Unit, +) { + tagCurrentNav2Scenario(scenario) SentryTraced( tag = "Nav2 /$routeName", // Keep interaction tagging off here so route wrappers do not turn every Compose click into a From 453271df09b2c4dfff4b56268910a1dd31d7f781 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 5 Oct 2026 10:32:23 +0200 Subject: [PATCH 3/4] Address second round of Sentry Bot comments --- .../samples/android/navigation/nav2/Nav2ComposeRoutes.kt | 9 +++++---- .../android/navigation/nav2/Nav2CustomTransactions.kt | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ComposeRoutes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ComposeRoutes.kt index 249cee35659..9d083ef7431 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ComposeRoutes.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2ComposeRoutes.kt @@ -133,10 +133,11 @@ internal fun Nav2ComposeApp( shareSheetProductId.value = null } - fun resetToHome() { - backStack.resetTo(Home) + fun resetToRoot() { + val root = if (selectedScenario == Nav2Scenario.CUSTOM) Custom else Home + backStack.resetTo(root) shareSheetProductId.value = null - navController.navigate(Home.route) { + navController.navigate(root.route) { popUpTo(Home.route) { inclusive = false } launchSingleTop = true } @@ -324,7 +325,7 @@ internal fun Nav2ComposeApp( Nav2ComposeConfirmationRoute( routeSpec = RouteSpecs.confirmation, orderId = entry.arguments?.getString(NavArgs.ORDER_ID).orEmpty(), - onResetBackStack = { resetToHome() }, + onResetBackStack = { resetToRoot() }, ) } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2CustomTransactions.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2CustomTransactions.kt index 26cf47c2c59..d4d5348bc46 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2CustomTransactions.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2CustomTransactions.kt @@ -113,9 +113,9 @@ internal class Nav2CustomTransactionController { private fun handleWholeFlow(currentRoute: String) { if (currentRoute == RouteNames.CUSTOM) { - if (activeMode == Nav2CustomTransactionMode.WHOLE_FLOW) { - finishActiveTransaction() - } + finishActiveTransaction() + activeMode = Nav2CustomTransactionMode.WHOLE_FLOW + activeRouteName = currentRoute return } From bef6b0f156716a656f674ca72eeb5d8298b6864b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 5 Oct 2026 10:42:43 +0200 Subject: [PATCH 4/4] fix(samples): Close route work OkHttp responses Close Retrofit response bodies for both synchronous and asynchronous route-work requests so repeated navigation cannot leak OkHttp connections. Co-Authored-By: Codex --- .../android/navigation/common/RouteWorkApi.kt | 23 ++++++++++++++++--- .../android/navigation/nav2/Nav2Activity.kt | 19 ++++----------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkApi.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkApi.kt index 8a4d337a373..8b12ee51fa3 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkApi.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkApi.kt @@ -32,12 +32,29 @@ internal object RouteWorkApi { private val service: RouteWorkService = retrofit.create(RouteWorkService::class.java) - fun enqueueRequest(callback: retrofit2.Callback) { - service.request(ROUTE_WORK_URL).enqueue(callback) + fun enqueueRequest(onSuccess: () -> Unit, onFailure: (Throwable) -> Unit) { + service + .request(ROUTE_WORK_URL) + .enqueue( + object : retrofit2.Callback { + override fun onResponse( + call: retrofit2.Call, + response: retrofit2.Response, + ) { + response.body()?.close() + response.errorBody()?.close() + onSuccess() + } + + override fun onFailure(call: retrofit2.Call, t: Throwable) { + onFailure(t) + } + } + ) } suspend fun runRequest() { - service.requestAsync(ROUTE_WORK_URL) + service.requestAsync(ROUTE_WORK_URL).use {} } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Activity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Activity.kt index ca353d202ec..0cbd4ac14b3 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Activity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav2/Nav2Activity.kt @@ -45,10 +45,6 @@ import io.sentry.samples.android.navigation.common.hasOnlyActivityUiLoadTransact import io.sentry.samples.android.navigation.common.showRouteWorkDialog import io.sentry.samples.android.navigation.nav2.Nav2Destination.Home import io.sentry.samples.android.navigation.nav2.Nav2Destination.Landing -import okhttp3.ResponseBody -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response /** * Sample activity for testing Sentry's @@ -396,16 +392,11 @@ class Nav2Activity : AppCompatActivity() { when (option) { RouteWorkOption.HTTP_REQUEST -> { RouteWorkApi.enqueueRequest( - 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() - } - } + onSuccess = { Thread { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }.start() }, + onFailure = { t -> + Sentry.captureException(t) + Thread { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }.start() + }, ) }