From bf02ae7a9accf3ab3060ff8825f0e4f44ba2cc85 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 5 Sep 2026 17:21:16 +0200 Subject: [PATCH 1/2] chore(android): Add product list to Nav2 sample Add a new hide/show product list button to the Nav2Activity. For the Compose tab, doing so lets us observe the span-producing of multiple SentryTraced composables inside the same nav transaction. (The list was also added to the Fragments tab to maintain UI parity.) --- .../android/navigation/Nav2ComposeRoutes.kt | 72 +++++++++- .../android/navigation/Nav2RouteFragment.kt | 126 ++++++++++++++++++ 2 files changed, 192 insertions(+), 6 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt index 2bb8c359f1..3a624b7f2f 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt @@ -27,11 +27,13 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -313,16 +315,47 @@ private fun Nav2ComposeHomeRoute(routeSpec: Nav2RouteSpec, onBrowseProducts: () Nav2ComposeActionRoute(routeSpec, buttons = listOf("Browse Products" to onBrowseProducts)) } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun Nav2ComposeProductListRoute( routeSpec: Nav2RouteSpec, onOpenProduct42: () -> Unit, onOpenProduct7: () -> Unit, ) { - Nav2ComposeActionRoute( - routeSpec, - buttons = listOf("Open Product 42" to onOpenProduct42, "Open Product 7" to onOpenProduct7), - ) + var showProductItems by rememberSaveable { mutableStateOf(false) } + + Nav2ComposeRouteScaffold( + routeSpec = routeSpec, + cardContent = { + SentryTraced( + tag = "product_list_actions", + modifier = Modifier.fillMaxWidth(), + enableUserInteractionTracing = false, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Nav2ComposeRouteButton("Open Product 42", onOpenProduct42) + Nav2ComposeRouteButton("Open Product 7", onOpenProduct7) + } + } + }, + ) { + Nav2ComposeRouteButton( + label = if (showProductItems) "Hide Product Items" else "Show Product Items", + onClick = { showProductItems = !showProductItems }, + ) + + if (showProductItems) { + SentryTraced( + tag = "product_list_items", + modifier = Modifier.fillMaxWidth(), + enableUserInteractionTracing = false, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + repeat(PRODUCT_LIST_ITEM_COUNT) { index -> Nav2ComposeProductListItem(index + 1) } + } + } + } + } } @Composable @@ -489,6 +522,7 @@ private fun Nav2ComposeShareSheetRoute( @Composable private fun Nav2ComposeRouteScaffold( routeSpec: Nav2RouteSpec, + cardContent: (@Composable ColumnScope.() -> Unit)? = null, content: (@Composable ColumnScope.() -> Unit)? = null, ) { Column( @@ -501,7 +535,7 @@ private fun Nav2ComposeRouteScaffold( fontWeight = FontWeight.Bold, ) routeSpec.description?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } - if (content != null) { + if (cardContent != null) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), modifier = Modifier.fillMaxWidth(), @@ -510,10 +544,34 @@ private fun Nav2ComposeRouteScaffold( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - content() + cardContent() } } } + content?.invoke(this) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun Nav2ComposeProductListItem(index: Int) { + SentryTraced( + tag = "product_list_item_$index", + modifier = Modifier.fillMaxWidth(), + enableUserInteractionTracing = false, + ) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Product #$index", fontWeight = FontWeight.Bold) + Text("SKU-$index") + } + } } } @@ -534,6 +592,8 @@ private fun Nav2ComposeRouteButton( private fun nav2ComposeInteractionTag(label: String): String = "Nav2 Compose $label" +private const val PRODUCT_LIST_ITEM_COUNT = 20 + @Composable private fun Nav2ComposeRouteInfo(label: String, value: String) { Row( diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteFragment.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteFragment.kt index f1bb141a39..b10e4cd55e 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/Nav2RouteFragment.kt @@ -7,10 +7,14 @@ import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.LinearLayout +import android.widget.ScrollView import android.widget.TextView +import androidx.core.view.doOnPreDraw import androidx.core.view.setPadding import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import io.sentry.ISpan +import io.sentry.Sentry import io.sentry.samples.android.R import kotlinx.coroutines.launch @@ -127,6 +131,7 @@ class Nav2RouteFragment : Fragment() { activity.navigateTo(Nav2Destination.Checkout(productId)) }, ), + trailingContent = { addProductDetailItemsToggle(productId) }, ) } @@ -163,6 +168,7 @@ class Nav2RouteFragment : Fragment() { routeSpec: Nav2RouteSpec, arguments: Bundle? = null, buttons: List = emptyList(), + trailingContent: (LinearLayout.() -> Unit)? = null, ): View { val info = routeSpec.displayArguments(arguments) return LinearLayout(requireContext()).apply { @@ -182,9 +188,121 @@ class Nav2RouteFragment : Fragment() { } ) } + trailingContent?.invoke(this) } } + private fun LinearLayout.addProductDetailItemsToggle(productId: String) { + var itemsCreated = false + var itemsVisible = false + val listContainer = + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, 8.dp, 0, 0) + } + val scrollView = + ScrollView(context).apply { + visibility = View.GONE + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 280.dp) + addView(listContainer, ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + } + + addView( + Button(context).apply { + text = "Show Product Items" + isAllCaps = false + setOnClickListener { + itemsVisible = !itemsVisible + text = if (itemsVisible) "Hide Product Items" else "Show Product Items" + if (itemsVisible && !itemsCreated) { + populateProductDetailItems(listContainer, productId) + itemsCreated = true + } + scrollView.visibility = if (itemsVisible) View.VISIBLE else View.GONE + } + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + } + ) + addView(scrollView) + } + + private fun populateProductDetailItems(listContainer: LinearLayout, productId: String) { + val ownerSpan = Sentry.getSpan() + val compositionParent = + ownerSpan?.startChild( + OP_PARENT_COMPOSITION, + "Fragment Product Detail Item List Composition", + ) + try { + recordManualUiSpan(compositionParent, OP_COMPOSE, "fragment_product_detail_items") { + repeat(PRODUCT_DETAIL_ITEM_COUNT) { index -> + val itemNumber = index + 1 + recordManualUiSpan( + compositionParent, + OP_COMPOSE, + "fragment_product_detail_item_$itemNumber", + ) { + listContainer.addView(productDetailItemRow(productId, itemNumber)) + } + } + } + } finally { + compositionParent?.finish() + } + + listContainer.doOnPreDraw { + val renderParent = + ownerSpan?.startChild( + OP_PARENT_RENDER, + "Fragment Product Detail Item List Render", + ) + try { + recordManualUiSpan(renderParent, OP_RENDER, "fragment_product_detail_items") + repeat(PRODUCT_DETAIL_ITEM_COUNT) { index -> + recordManualUiSpan(renderParent, OP_RENDER, "fragment_product_detail_item_${index + 1}") + } + } finally { + renderParent?.finish() + } + } + } + + private fun recordManualUiSpan( + parentSpan: ISpan?, + operation: String, + description: String, + block: () -> Unit = {}, + ) { + val span = parentSpan?.startChild(operation, description) + span?.setData("sample.nav2_fragment_manual_ui_span", true) + try { + block() + } finally { + span?.finish() + } + } + + private fun productDetailItemRow(productId: String, itemNumber: Int): View = + LinearLayout(requireContext()).apply { + orientation = LinearLayout.HORIZONTAL + setPadding(12.dp) + setBackgroundColor(color(android.R.color.white)) + addView( + TextView(context).apply { + text = "Product $productId item #$itemNumber" + textSize = 14f + setTypeface(null, Typeface.BOLD) + layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) + } + ) + addView( + TextView(context).apply { + text = "SKU-$productId-$itemNumber" + textSize = 14f + } + ) + } + private fun titleText(textValue: String): TextView = TextView(requireContext()).apply { text = textValue @@ -224,4 +342,12 @@ class Nav2RouteFragment : Fragment() { get() = (this * resources.displayMetrics.density).toInt() private fun color(id: Int): Int = requireContext().getColor(id) + + private companion object { + private const val PRODUCT_DETAIL_ITEM_COUNT = 20 + private const val OP_PARENT_COMPOSITION = "ui.compose.composition" + private const val OP_COMPOSE = "ui.compose" + private const val OP_PARENT_RENDER = "ui.compose.rendering" + private const val OP_RENDER = "ui.render" + } } From 453a27e33cc1e92dd9bbbee239c9bd7b4e14617a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 5 Sep 2026 18:11:47 +0200 Subject: [PATCH 2/2] Address Sentry bot issues --- .../android/navigation/Nav2ComposeRoutes.kt | 15 ++++---- .../android/navigation/Nav2RouteFragment.kt | 34 +++++++++---------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt index 3a624b7f2f..c2e2a73040 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2ComposeRoutes.kt @@ -421,12 +421,15 @@ private fun Nav2ComposeActionRoute( arguments: Map = emptyMap(), buttons: List Unit>>, ) { - Nav2ComposeRouteScaffold(routeSpec) { - routeSpec.displayArguments(arguments).forEach { (label, value) -> - Nav2ComposeRouteInfo(label, value) - } - buttons.forEach { (label, onClick) -> Nav2ComposeRouteButton(label, onClick) } - } + Nav2ComposeRouteScaffold( + routeSpec = routeSpec, + cardContent = { + routeSpec.displayArguments(arguments).forEach { (label, value) -> + Nav2ComposeRouteInfo(label, value) + } + buttons.forEach { (label, onClick) -> Nav2ComposeRouteButton(label, onClick) } + }, + ) } @Composable diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteFragment.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/Nav2RouteFragment.kt index b10e4cd55e..50796b4f6c 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/Nav2RouteFragment.kt @@ -68,6 +68,7 @@ class Nav2RouteFragment : Fragment() { activity.navigateTo(Nav2Destination.ProductDetail("7", "product-list")) }, ), + trailingContent = { addProductListItemsToggle() }, ) Nav2RouteNames.PRODUCT_DETAIL -> productDetailLayout(activity) @@ -131,7 +132,6 @@ class Nav2RouteFragment : Fragment() { activity.navigateTo(Nav2Destination.Checkout(productId)) }, ), - trailingContent = { addProductDetailItemsToggle(productId) }, ) } @@ -192,7 +192,7 @@ class Nav2RouteFragment : Fragment() { } } - private fun LinearLayout.addProductDetailItemsToggle(productId: String) { + private fun LinearLayout.addProductListItemsToggle() { var itemsCreated = false var itemsVisible = false val listContainer = @@ -215,7 +215,7 @@ class Nav2RouteFragment : Fragment() { itemsVisible = !itemsVisible text = if (itemsVisible) "Hide Product Items" else "Show Product Items" if (itemsVisible && !itemsCreated) { - populateProductDetailItems(listContainer, productId) + populateProductListItems(listContainer) itemsCreated = true } scrollView.visibility = if (itemsVisible) View.VISIBLE else View.GONE @@ -226,23 +226,23 @@ class Nav2RouteFragment : Fragment() { addView(scrollView) } - private fun populateProductDetailItems(listContainer: LinearLayout, productId: String) { + private fun populateProductListItems(listContainer: LinearLayout) { val ownerSpan = Sentry.getSpan() val compositionParent = ownerSpan?.startChild( OP_PARENT_COMPOSITION, - "Fragment Product Detail Item List Composition", + "Fragment Product List Item List Composition", ) try { - recordManualUiSpan(compositionParent, OP_COMPOSE, "fragment_product_detail_items") { - repeat(PRODUCT_DETAIL_ITEM_COUNT) { index -> + recordManualUiSpan(compositionParent, OP_COMPOSE, "fragment_product_list_items") { + repeat(PRODUCT_LIST_ITEM_COUNT) { index -> val itemNumber = index + 1 recordManualUiSpan( compositionParent, OP_COMPOSE, - "fragment_product_detail_item_$itemNumber", + "fragment_product_list_item_$itemNumber", ) { - listContainer.addView(productDetailItemRow(productId, itemNumber)) + listContainer.addView(productListItemRow(itemNumber)) } } } @@ -254,12 +254,12 @@ class Nav2RouteFragment : Fragment() { val renderParent = ownerSpan?.startChild( OP_PARENT_RENDER, - "Fragment Product Detail Item List Render", + "Fragment Product List Item List Render", ) try { - recordManualUiSpan(renderParent, OP_RENDER, "fragment_product_detail_items") - repeat(PRODUCT_DETAIL_ITEM_COUNT) { index -> - recordManualUiSpan(renderParent, OP_RENDER, "fragment_product_detail_item_${index + 1}") + recordManualUiSpan(renderParent, OP_RENDER, "fragment_product_list_items") + repeat(PRODUCT_LIST_ITEM_COUNT) { index -> + recordManualUiSpan(renderParent, OP_RENDER, "fragment_product_list_item_${index + 1}") } } finally { renderParent?.finish() @@ -282,14 +282,14 @@ class Nav2RouteFragment : Fragment() { } } - private fun productDetailItemRow(productId: String, itemNumber: Int): View = + private fun productListItemRow(itemNumber: Int): View = LinearLayout(requireContext()).apply { orientation = LinearLayout.HORIZONTAL setPadding(12.dp) setBackgroundColor(color(android.R.color.white)) addView( TextView(context).apply { - text = "Product $productId item #$itemNumber" + text = "Product #$itemNumber" textSize = 14f setTypeface(null, Typeface.BOLD) layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) @@ -297,7 +297,7 @@ class Nav2RouteFragment : Fragment() { ) addView( TextView(context).apply { - text = "SKU-$productId-$itemNumber" + text = "SKU-$itemNumber" textSize = 14f } ) @@ -344,7 +344,7 @@ class Nav2RouteFragment : Fragment() { private fun color(id: Int): Int = requireContext().getColor(id) private companion object { - private const val PRODUCT_DETAIL_ITEM_COUNT = 20 + private const val PRODUCT_LIST_ITEM_COUNT = 20 private const val OP_PARENT_COMPOSITION = "ui.compose.composition" private const val OP_COMPOSE = "ui.compose" private const val OP_PARENT_RENDER = "ui.compose.rendering"