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..8b12ee51fa3 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/common/RouteWorkApi.kt @@ -0,0 +1,67 @@ +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(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).use {} + } +} + +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 86% 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..0cbd4ac14b3 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,14 +33,18 @@ import androidx.navigation.fragment.NavHostFragment import io.sentry.Sentry import io.sentry.SpanStatus import io.sentry.android.navigation.SentryNavigationListener -import io.sentry.samples.android.GithubAPI import io.sentry.samples.android.R -import io.sentry.samples.android.Repo -import io.sentry.samples.android.navigation.Nav2Destination.Home -import io.sentry.samples.android.navigation.Nav2Destination.Landing -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response +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 /** * Sample activity for testing Sentry's @@ -66,17 +70,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 +207,11 @@ class Nav2Activity : AppCompatActivity() { routeWorkOptions = routeWorkOptions.value, onCaptureException = { captureSampleException("Nav2") }, onCrashApp = { showCrashConfirmation("Nav2") }, - onRouteChanged = { _, currentRoute, backStack -> - updateComposeNavigationUi(currentRoute, backStack) + selectedScenario = composeScenario.value, + onRouteChanged = { scenario, _, currentRoute, backStack -> + updateComposeNavigationUi(scenario, currentRoute, backStack) }, + onExitRoot = { finish() }, ) } } @@ -258,15 +264,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 +330,7 @@ class Nav2Activity : AppCompatActivity() { private fun openScenario(scenario: Nav2Scenario) { activeScenario = scenario + composeScenario.value = scenario topBar.select(activeScenario) performanceState.stopAutomaticWork() when (scenario) { @@ -334,7 +341,22 @@ class Nav2Activity : AppCompatActivity() { navController.currentBackStackEntry?.arguments, ) } - Nav2Scenario.COMPOSE -> contentHosts.showCompose() + Nav2Scenario.COMPOSE -> { + contentHosts.showCompose() + updateComposeNavigationUi( + scenario = Nav2Scenario.COMPOSE, + currentRoute = "/${RouteNames.HOME}", + backStack = "/${RouteNames.HOME}", + ) + } + Nav2Scenario.CUSTOM -> { + contentHosts.showCompose() + updateComposeNavigationUi( + scenario = Nav2Scenario.CUSTOM, + currentRoute = "/${RouteNames.CUSTOM}", + backStack = "/${RouteNames.CUSTOM}", + ) + } Nav2Scenario.FRAGMENTS -> { contentHosts.showFragments() resetToHome() @@ -369,20 +391,13 @@ 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( + onSuccess = { Thread { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }.start() }, + onFailure = { t -> + Sentry.captureException(t) + Thread { Sentry.flush(SENTRY_FLUSH_TIMEOUT_MILLIS) }.start() + }, + ) } RouteWorkOption.MANUAL_CHILD_SPAN -> recordManualChildSpan(routeName) @@ -393,16 +408,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 +428,7 @@ class Nav2Activity : AppCompatActivity() { } private fun replaceNav2PerformanceTop() { - traceNavigationPerformanceSection("Nav2Stress.replaceTop") { + traceNav2PerformanceSection("Nav2Stress.replaceTop") { val generation = performanceState.nextGeneration() if (backStack.size > 1) { navigateBack() @@ -456,9 +471,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( @@ -472,9 +487,13 @@ class Nav2Activity : AppCompatActivity() { } } - private fun updateComposeNavigationUi(currentRoute: String, backStack: String) { + private fun updateComposeNavigationUi( + scenario: Nav2Scenario, + currentRoute: String, + backStack: String, + ) { topBar.update( - scenario = Nav2Scenario.COMPOSE, + scenario = scenario, currentRoute = currentRoute, backStack = backStack, ) @@ -605,7 +624,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 +638,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 57% 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..9d083ef7431 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 @@ -31,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 @@ -39,7 +47,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 +63,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 +94,24 @@ internal fun Nav2ComposeApp( routeWorkOptions: Set, onCaptureException: () -> Unit, onCrashApp: () -> Unit, - onRouteChanged: (routeName: String, currentRoute: String, backStack: String) -> Unit, + selectedScenario: Nav2Scenario, + onRouteChanged: + (scenario: Nav2Scenario, 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 remember { 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) @@ -99,20 +133,53 @@ 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 } } - 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 = false } + launchSingleTop = true + } + } + Nav2Scenario.CUSTOM -> { + backStack.resetTo(Custom) + shareSheetProductId.value = null + navController.navigate(Custom.route) { + popUpTo(Home.route) { inclusive = false } + 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( + selectedScenario, currentDestination.routeName, currentDestination.displayRoute(), backStack.toComposeBackStackText(), @@ -124,23 +191,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) } + TracedNav2ComposeRoute(selectedScenario, Home.routeName) { + Nav2ComposeHomeRoute(routeSpec = RouteSpecs.home) { navigateTo(ProductList) } + } + } + + composable(Custom.route) { + TracedNav2ComposeRoute(selectedScenario, 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) { + TracedNav2ComposeRoute(selectedScenario, ProductList.routeName) { Nav2ComposeProductListRoute( - routeSpec = Nav2RouteSpecs.productList, + routeSpec = RouteSpecs.productList, onOpenProduct42 = { navigateTo( ProductDetail( @@ -161,20 +275,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(selectedScenario, RouteNames.PRODUCT_DETAIL) { Nav2ComposeProductDetailRoute( - routeSpec = Nav2RouteSpecs.productDetail, + routeSpec = RouteSpecs.productDetail, productId = productId, source = source, campaign = campaign, @@ -189,12 +303,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(selectedScenario, RouteNames.CHECKOUT) { Nav2ComposeCheckoutRoute( - routeSpec = Nav2RouteSpecs.checkout, + routeSpec = RouteSpecs.checkout, productId = productId, onCompleteOrder = { navigateTo(Confirmation(orderId = "order-$productId")) @@ -205,28 +319,28 @@ 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(selectedScenario, RouteNames.CONFIRMATION) { Nav2ComposeConfirmationRoute( - routeSpec = Nav2RouteSpecs.confirmation, - orderId = entry.arguments?.getString(Nav2Args.ORDER_ID).orEmpty(), - onResetBackStack = { resetToHome() }, + routeSpec = RouteSpecs.confirmation, + orderId = entry.arguments?.getString(NavArgs.ORDER_ID).orEmpty(), + onResetBackStack = { resetToRoot() }, ) } } 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(selectedScenario, 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 +349,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, @@ -253,8 +382,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 @@ -299,7 +432,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 +447,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 +567,7 @@ private fun Nav2ComposeProductListRoute( @Composable private fun Nav2ComposeProductDetailRoute( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, productId: String, source: String, campaign: String, @@ -372,16 +576,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 +598,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 +641,7 @@ private fun Nav2ComposeActionRoute( @Composable private fun Nav2ComposePromoDialogRoute( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, promoId: String, onCaptureException: () -> Unit, onCrashApp: () -> Unit, @@ -448,7 +652,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 +687,7 @@ private fun Nav2ComposePromoDialogRoute( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun Nav2ComposeShareSheetRoute( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, productId: String, onCaptureException: () -> Unit, onCrashApp: () -> Unit, @@ -496,8 +700,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 +730,7 @@ private fun Nav2ComposeShareSheetRoute( @Composable private fun Nav2ComposeRouteScaffold( - routeSpec: Nav2RouteSpec, + routeSpec: RouteSpec, cardContent: (@Composable ColumnScope.() -> Unit)? = null, content: (@Composable ColumnScope.() -> Unit)? = null, ) { @@ -602,6 +805,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 +855,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 +873,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 +917,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..d4d5348bc46 --- /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) { + finishActiveTransaction() + activeMode = Nav2CustomTransactionMode.WHOLE_FLOW + activeRouteName = currentRoute + 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 @@ +