diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt index 9a571d47dee..4b45d7c5b21 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt @@ -3,7 +3,7 @@ package io.sentry.compose.navigation3 import androidx.compose.runtime.Immutable import org.jetbrains.annotations.ApiStatus -// Keep the default low: every captured entry may require argument extraction and recursive +// Keep the default low, as every captured entry may require argument extraction and recursive // sanitization when navigation changes are observed. private const val DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES = 5 diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt index 0068ce0d33e..87b0f8f75ba 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt @@ -19,8 +19,8 @@ import io.sentry.SpanStatus * **Exception semantics** * * Methods in this class with an `IScope` parameter can throw arbitrary exceptions†, which propagate - * to the caller. This class restores its own bookkeeping first, so the coordinator remains usable - * if the exception is caught. + * to the caller. Each method updates its own bookkeeping before calling into the scope, so the + * coordinator remains usable if the exception is caught. * * † E.g., `IScope` can throw arbitrary exceptions when scope mutators notify registered observers – * including the host app – in an unguarded loop, or when `finish()` is called on a retained nav @@ -172,35 +172,38 @@ internal class NavLeaseCoordinator { * left to time out, its published data is left in place, and the lease is released rather than * handed off. * - * Coordinator references are released even if scope cleanup or lease handoff throws. See - * "Exception semantics" in the class KDoc. The remaining scope cleanup is then skipped. + * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. Coordinator + * references to `owner` are released before any scope call, so they're released even if scope + * cleanup or lease handoff throws. The remaining scope cleanup is then skipped. */ fun unregisterCandidate(owner: Any, scope: IScope?) { val candidate = findCandidate(owner) ?: return + val transaction = candidate.transaction + val wasPublisher = publishedDataCandidate === candidate + val wasLeaseHolder = leaseHolder === candidate - try { - finishTransaction(candidate, scope) - candidates.remove(candidate) + candidates.remove(candidate) + candidate.transaction = null + if (wasPublisher) { + publishedDataCandidate = null + } + if (wasLeaseHolder) { + leaseHolder = null + } - if (publishedDataCandidate === candidate) { - publishedDataCandidate = null - scope?.let { candidate.onClearPublishedData(it) } - } + // No need to finish the transaction if we don't have a scope (e.g., the SDK is disabled), as + // the transaction can't be sent and it will time out anyway. Not finishing it has the added + // advantage of keeping SDK work inside `configureScope`'s error handling. + if (scope == null) { + return + } - if (leaseHolder === candidate) { - handOffLease(mostRecentlyResumedCandidate(), scope) - } - } finally { - // Scope observers and transaction callbacks can throw. Never retain a departing owner - // just because cleanup failed, and preserve any successor selected during handoff. - candidates.remove(candidate) - candidate.transaction = null - if (publishedDataCandidate === candidate) { - publishedDataCandidate = null - } - if (leaseHolder === candidate) { - leaseHolder = null - } + finishTransaction(transaction, scope) + if (wasPublisher) { + candidate.onClearPublishedData(scope) + } + if (wasLeaseHolder) { + transferLeaseTo(mostRecentlyResumedCandidate(), scope) } } @@ -334,8 +337,8 @@ internal class NavLeaseCoordinator { val transaction = candidate.transaction if (transaction?.isFinished == true) { - clearTransactionFromScope(candidate, scope) candidate.transaction = null + clearTransactionFromScope(transaction, scope) } else if (transaction != null) { // The navigation lease doesn't give this owner control over an unrelated transaction // already bound to the shared scope. Restore the owner's retained nav transaction only if @@ -348,47 +351,40 @@ internal class NavLeaseCoordinator { } } + val previousPublisher = publishedDataCandidate leaseHolder = candidate if (candidate != null) { + // Record the candidate as the publisher before calling into the scope: it owns the scope's + // nav data from the moment it takes the lease. If clearing the previous owner's data then + // throws, the next handoff still clears the candidate's data rather than never clearing it. + // // Clear the previous publisher's nav data before publishing the candidate's, so the // previous owner's values don't linger where the candidate has nothing staged to replace // them. - val previousPublisher = publishedDataCandidate - try { - if (previousPublisher != null && previousPublisher !== candidate) { - previousPublisher.onClearPublishedData(scope) - } - publishedDataCandidate = candidate - candidate.onPublishData(scope) - } finally { - // Scope observers can throw. A candidate owns the scope's nav data from the moment it - // takes the lease, so record it as the publisher even if clearing the previous owner's - // data threw. Leaving the previous owner recorded here would strand both owners' values - // b/c the next handoff would clear data the candidate may have already replaced, and would - // never clear the candidate's own. - publishedDataCandidate = candidate + publishedDataCandidate = candidate + if (previousPublisher != null && previousPublisher !== candidate) { + previousPublisher.onClearPublishedData(scope) } + candidate.onPublishData(scope) } } - /** Finishes and unbinds [candidate]'s nav transaction, then forgets it. */ - private fun finishTransaction(candidate: Candidate, scope: IScope?) { - // No need to finish the transaction if we don't have a scope (e.g., the SDK is disabled), as - // the transaction can't be sent and it will time out anyway. Not finishing it has the added - // advantage of keeping SDK work inside `configureScope`'s error handling. - if (scope != null) { - val transaction = candidate.transaction - if (transaction != null && !transaction.isFinished) { - transaction.finish(transaction.status ?: SpanStatus.OK) - } - clearTransactionFromScope(candidate, scope) + /** + * Finishes [transaction] if it's unfinished, and unbinds it from [scope]. + * + * Callers must forget the transaction first. + */ + private fun finishTransaction(transaction: ITransaction?, scope: IScope) { + if (transaction == null) { + return } - - candidate.transaction = null + if (!transaction.isFinished) { + transaction.finish(transaction.status ?: SpanStatus.OK) + } + clearTransactionFromScope(transaction, scope) } - private fun clearTransactionFromScope(candidate: Candidate, scope: IScope) { - val transaction = candidate.transaction ?: return + private fun clearTransactionFromScope(transaction: ITransaction, scope: IScope) { scope.withTransaction { current -> if (current === transaction) { scope.clearTransaction() diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt index 964e3694ed6..a32cc74d506 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt @@ -4,7 +4,7 @@ import io.sentry.IScope import io.sentry.PropagationContext /** - * Holds navigation state staged for publication: a screen name and back stack nav context. + * Holds navigation state staged for publication. * * Only state that occupies a single slot in the shared [IScope] is staged. Nav events (i.e., * breadcrumbs) only append to the scope, so they can't overwrite another instance's data and are diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt index 61e5ef2968e..44986848b5b 100644 --- a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt @@ -269,25 +269,9 @@ class NavLeaseCoordinatorTest { } /** - * An owner that leaves must not leave its nav data behind on the shared scope. - * - * The coordinator records which owner last published nav data. Each handoff uses that record to - * clear the previous owner's screen and back stack, then publishes the incoming owner's. If the - * clear attempt throws, the coordinator must still record the incoming owner as the one that last - * published. - * - * This test runs two handoffs over three owners. The first owner publishes; the second takes the - * lease; and then the first owner's clear attempt throws. The third then takes the lease. The - * test verifies that third handoff clears the second owner's data, and not the first's. That lets - * us ensure the second problem listed below doesn't happen. - * - * In the setup above, if the coordinator didn't record the second owner's data after the first - * owner's clear attempt threw, two things would happen when handing off to the third owner: - * - * 1. Harmless: The handoff logic would try to clear the first owner's data again. That'd remove - * nothing because the scope no longer holds the values the first owner published. - * 2. Problematic: The handoff would never clear the second owner's data. Instead, the second - * owner's data would stay on the scope, so later events would report stale nav data. + * The coordinator records the incoming owner as the publisher before calling into the scope, so a + * clear callback that throws mid-handoff doesn't strand the incoming owner's nav data: the next + * handoff still clears it. */ @Test fun `if a clear attempt throws during a handoff, the next handoff clears the new leaseholder's nav data`() { @@ -323,71 +307,6 @@ class NavLeaseCoordinatorTest { assertThat(fixture.cleared).containsExactly(secondOwner) } - /** - * The same guarantee as - * [if a clear attempt throws during a handoff, the next handoff clears the new leaseholder's nav data], - * but for a failure while publishing instead of while clearing. - * - * This case already worked before the fix, because the coordinator records the incoming owner - * before it publishes. The test exists to catch a future change that moves the record after the - * publish. - */ - @Test - fun `if a publish attempt throws during a handoff, the next handoff clears the new leaseholder's nav data`() { - val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - val failure = IllegalStateException("publish failed") - val firstOwner = previous - val secondOwner = incoming - val thirdOwner = owner - fixture.acquireLease(firstOwner) - navLeaseCoordinator.registerCandidate( - secondOwner, - Lifecycle.State.RESUMED, - onPublishData = { throw failure }, - onClearPublishedData = { fixture.cleared += secondOwner }, - ) - - val thrown = - assertThrows(IllegalStateException::class.java) { - navLeaseCoordinator.onBackStackUpdate(secondOwner, fixture.scope) - } - - assertThat(thrown).isSameInstanceAs(failure) - assertThat(navLeaseCoordinator.hasLease(secondOwner)).isTrue() - - fixture.acquireLease(thirdOwner) - - assertThat(fixture.cleared).containsExactly(firstOwner, secondOwner).inOrder() - } - - @Test - fun `unregistering an owner that threw while clearing its nav data preserves the new leaseholder's nav data`() { - val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - val failure = IllegalStateException("clear failed") - var failClear = true - fixture.acquireLease( - previous, - onClearPublishedData = { - if (failClear) { - failClear = false - throw failure - } - fixture.cleared += previous - }, - ) - fixture.register(incoming, Lifecycle.State.RESUMED) - assertThrows(IllegalStateException::class.java) { - navLeaseCoordinator.onBackStackUpdate(incoming, fixture.scope) - } - - navLeaseCoordinator.unregisterCandidate(previous, fixture.scope) - - assertThat(navLeaseCoordinator.hasLease(incoming)).isTrue() - assertThat(fixture.cleared).isEmpty() - } - @Test fun `later back stack updates do not republish nav data or reclaim the nav lease`() { val fixture = Fixture.create() @@ -914,120 +833,6 @@ class NavLeaseCoordinatorTest { assertThat(fixture.cleared).isEmpty() } - @Test - fun `unregistering the leaseholder forgets the owner when unbinding its transaction throws`() { - val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - val failure = IllegalStateException("unbind failed") - val failingScope = mock() - fixture.acquireLease(owner, transaction = transaction) - doThrow(failure).whenever(failingScope).withTransaction(any()) - - val thrown = - assertThrows(IllegalStateException::class.java) { - navLeaseCoordinator.unregisterCandidate(owner, failingScope) - } - - assertThat(thrown).isSameInstanceAs(failure) - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() - assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() - - fixture.published.clear() - navLeaseCoordinator.resumeCandidate(owner, fixture.scope) - fixture.acquireLease(incoming) - - assertThat(fixture.published).containsExactly(incoming) - assertThat(fixture.cleared).isEmpty() - } - - @Test - fun `unregistering the leaseholder forgets the owner when clearing its published nav data throws`() { - val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - val failure = IllegalStateException("clear failed") - fixture.acquireLease(owner, onClearPublishedData = { throw failure }) - - val thrown = - assertThrows(IllegalStateException::class.java) { - navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) - } - - assertThat(thrown).isSameInstanceAs(failure) - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() - - fixture.published.clear() - navLeaseCoordinator.resumeCandidate(owner, fixture.scope) - fixture.acquireLease(incoming) - - assertThat(fixture.published).containsExactly(incoming) - assertThat(navLeaseCoordinator.hasLease(incoming)).isTrue() - } - - @Test - fun `unregistering the leaseholder forgets the owner when restoring a fallback transaction throws`() { - val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - val failure = IllegalStateException("fallback restoration failed") - val failingScope = mock() - fixture.acquireLease(previous, transaction = transaction) - fixture.acquireLease(owner) - doThrow(failure).whenever(failingScope).withTransaction(any()) - - val thrown = - assertThrows(IllegalStateException::class.java) { - navLeaseCoordinator.unregisterCandidate(owner, failingScope) - } - - assertThat(thrown).isSameInstanceAs(failure) - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() - assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isTrue() - - fixture.published.clear() - fixture.cleared.clear() - navLeaseCoordinator.resumeCandidate(owner, fixture.scope) - fixture.acquireLease(incoming) - - assertThat(fixture.published).containsExactly(incoming) - assertThat(fixture.cleared).doesNotContain(owner) - } - - @Test - fun `unregistering the leaseholder preserves the fallback lease when publishing fallback nav data throws`() { - val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - val failure = IllegalStateException("fallback publication failed") - var failPublication = false - navLeaseCoordinator.registerCandidate( - previous, - Lifecycle.State.RESUMED, - onPublishData = { - if (failPublication) { - throw failure - } - }, - onClearPublishedData = {}, - ) - navLeaseCoordinator.onBackStackUpdate(previous, fixture.scope) - fixture.acquireLease(owner, transaction = transaction) - failPublication = true - - val thrown = - assertThrows(IllegalStateException::class.java) { - navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) - } - - assertThat(thrown).isSameInstanceAs(failure) - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() - assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() - assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() - - fixture.published.clear() - navLeaseCoordinator.resumeCandidate(owner, fixture.scope) - - assertThat(fixture.published).isEmpty() - assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() - } - private class Fixture { val navLeaseCoordinator = NavLeaseCoordinator() val scope = Scope(SentryOptions()) diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index cf4b054e354..a9d4ea8ac5f 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -208,6 +208,7 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.recyclerview) + implementation(libs.androidx.viewpager2) implementation(libs.androidx.browser) implementation(libs.androidx.room3.runtime) implementation(libs.bundles.androidx.room2) @@ -230,6 +231,9 @@ dependencies { debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) + + testImplementation(libs.google.truth) + testImplementation(libs.kotlin.test.junit) } abstract class ToggleNativeLoggingTask : Exec() { diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 4e80ddfd865..da8b00d97a0 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -123,6 +123,30 @@ android:screenOrientation="portrait" android:theme="@style/AppTheme.Main" /> + + + + + + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt index 5b935a01e01..3fad5ffe277 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import io.sentry.compose.navigation3.SentryBackStackEntry @@ -75,9 +77,6 @@ class Nav3Activity : ComponentActivity() { private var routeWorkOptions by mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN)) - private var isTransactionHistoryActive = false - private val transactionHistory = - NavigationTransactionHistory(isActive = { isTransactionHistoryActive }) private var showTransactionHistorySheet by mutableStateOf(false) private var showActivityUiLoadTransactionDelayMessage = false @@ -99,14 +98,14 @@ class Nav3Activity : ComponentActivity() { cancelCurrentActivityUiLoadTransaction() } - transactionHistory.install() + NavigationTransactionHistory.install() setContent { Nav3SampleTheme { Nav3SampleApp( performanceState = performanceState, configuration = configuration, - transactions = transactionHistory.transactions, + transactions = NavigationTransactionHistory.transactions, showActivityUiLoadTransactionDelayMessage = showActivityUiLoadTransactionDelayMessage, routeWorkOptions = routeWorkOptions, showTransactionHistorySheet = showTransactionHistorySheet, @@ -124,13 +123,7 @@ class Nav3Activity : ComponentActivity() { } } - override fun onStart() { - super.onStart() - isTransactionHistoryActive = true - } - override fun onStop() { - isTransactionHistoryActive = false performanceState.stopAutomaticWork() super.onStop() } @@ -139,7 +132,6 @@ class Nav3Activity : ComponentActivity() { if (isFinishing) { previousConfig.applyToCurrentOptions() } - transactionHistory.uninstall() super.onDestroy() } @@ -361,7 +353,9 @@ private fun Nav3SampleApp( } Scaffold( - modifier = Modifier.fillMaxSize().safeDrawingPadding(), + // Expose test tags as resource IDs so adb/uiautomator can target them. + modifier = + Modifier.fillMaxSize().safeDrawingPadding().semantics { testTagsAsResourceId = true }, containerColor = MaterialTheme.colorScheme.background, topBar = { Nav3TopBar( @@ -464,6 +458,28 @@ private fun Nav3SampleApp( ) } } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + MultiActivityRoute { + activity?.startActivity( + Intent(activity, Nav3ProductsActivity::class.java) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) + ) + } + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + MultiFragmentRoute { + activity?.startActivity( + Intent(activity, Nav3FragmentsActivity::class.java) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) + ) + } + } + } entry { route -> TracedNav3Route(route, selectedScenario) { Nav3RouteWorkEffect(route, routeWorkOptions) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt new file mode 100644 index 00000000000..5e6ca0b9d07 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt @@ -0,0 +1,493 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +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.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.currentStateAsState +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner +import io.sentry.compose.SentryModifier.sentryTag +import io.sentry.compose.navigation3.SentryBackStackEntry +import io.sentry.compose.navigation3.SentryNavEffect +import io.sentry.compose.navigation3.SentryNavOptions +import io.sentry.samples.android.navigation.common.NavigationSampleConfig +import io.sentry.samples.android.navigation.common.RouteWorkOption +import io.sentry.samples.android.navigation.common.hasOnlyActivityUiLoadTransactions +import io.sentry.samples.android.navigation.common.showRouteWorkDialog +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/* + * Activities that each own a Nav3 root (back stack, `SentryNavEffect`, and `NavDisplay`), which let us + * exercise how separate effects hand off during Activity transactions. + */ + +/** Keeps its navigation effect alive while checkout runs in another Activity. */ +class Nav3ProductsActivity : ComponentActivity() { + + private var backStack: SnapshotStateList? = null + + /** An order ID returned before [backStack] exists (e.g., after Activity recreation). */ + private var pendingOrderId: String? = null + + // Pushes a destination from the result callback itself, which runs before this Activity resumes, + // like an app that navigates in response to an Activity result. + private val checkoutForResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val orderId = result.data?.getStringExtra(EXTRA_ORDER_ID) ?: return@registerForActivityResult + backStack?.add(Nav3Route.Confirmation(orderId)) ?: run { pendingOrderId = orderId } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration() + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + setContent { + val stack = rememberSaveableNav3BackStack(Nav3Route.ProductList) + SideEffect { + backStack = stack + pendingOrderId?.let { orderId -> + pendingOrderId = null + stack.add(Nav3Route.Confirmation(orderId)) + } + } + Nav3SampleTheme { + MultiRootNavDisplay( + activity = this, + title = "Products Activity", + initialRoute = Nav3Route.ProductList, + scenario = Nav3Scenario.MULTI_ACTIVITY, + configuration = configuration, + onFinish = { finish() }, + backStack = stack, + onCheckout = { productId -> startActivity(checkoutIntent(productId, configuration)) }, + productDetailActions = + listOf( + ProductDetailAction( + label = "Go to Translucent Checkout", + testTag = nav3TestTag("product_detail_go_to_translucent_checkout"), + onClick = { productId -> + startActivity( + checkoutIntent(productId, configuration) + .setClass(this, Nav3TranslucentCheckoutActivity::class.java) + ) + }, + ), + ProductDetailAction( + label = "Go to Checkout for Result", + testTag = nav3TestTag("product_detail_go_to_checkout_for_result"), + onClick = { productId -> + checkoutForResult.launch( + checkoutIntent(productId, configuration).putExtra(EXTRA_RETURN_RESULT, true) + ) + }, + ), + ProductDetailAction( + label = "Go to Checkout (Crash on First Composition)", + testTag = nav3TestTag("product_detail_go_to_checkout_crash"), + onClick = { productId -> + startActivity( + checkoutIntent(productId, configuration) + .putExtra(EXTRA_CRASH_ON_FIRST_COMPOSITION, true) + ) + }, + ), + ), + ) + } + } + } + + override fun onDestroy() { + backStack = null + super.onDestroy() + } + + private fun checkoutIntent(productId: String, configuration: NavigationSampleConfig): Intent = + Intent(this, Nav3CheckoutActivity::class.java) + .putExtra(EXTRA_PRODUCT_ID, productId) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) +} + +/** Owns the checkout back stack independently of the Products Activity. */ +open class Nav3CheckoutActivity : ComponentActivity() { + + /** Whether to show checkout as a sheet, leaving the Activity underneath partly visible. */ + protected open val showAsSheet: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration() + val productId = intent.getStringExtra(EXTRA_PRODUCT_ID) ?: "42" + val returnsResult = intent.getBooleanExtra(EXTRA_RETURN_RESULT, false) + // Only crash on the first launch, not when the system restores this Activity. + val crashOnFirstComposition = + savedInstanceState == null && intent.getBooleanExtra(EXTRA_CRASH_ON_FIRST_COMPOSITION, false) + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + setContent { + Nav3SampleTheme { + if (showAsSheet) { + CheckoutSheet { + CheckoutRoot(productId, configuration, returnsResult, crashOnFirstComposition) + } + } else { + CheckoutRoot(productId, configuration, returnsResult, crashOnFirstComposition) + } + } + } + } + + @Composable + private fun CheckoutRoot( + productId: String, + configuration: NavigationSampleConfig, + returnsResult: Boolean, + crashOnFirstComposition: Boolean, + ) { + MultiRootNavDisplay( + activity = this, + title = if (showAsSheet) "Translucent Checkout Activity" else "Checkout Activity", + initialRoute = Nav3Route.Checkout(productId), + scenario = Nav3Scenario.MULTI_ACTIVITY, + configuration = configuration, + onFinish = { finish() }, + onCompleteOrder = + if (returnsResult) { + { orderId -> + setResult(RESULT_OK, Intent().putExtra(EXTRA_ORDER_ID, orderId)) + finish() + } + } else { + null + }, + crashOnFirstComposition = crashOnFirstComposition, + ) + } +} + +/** + * Checkout in a translucent Activity. The Products Activity underneath stays visible, so it's + * paused rather than stopped while checkout is open. + */ +class Nav3TranslucentCheckoutActivity : Nav3CheckoutActivity() { + override val showAsSheet: Boolean = true +} + +/** Shows [content] as a bottom sheet over a scrim, leaving the top of the screen see-through. */ +@Composable +private fun CheckoutSheet(content: @Composable () -> Unit) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f))) { + Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.7f).align(Alignment.BottomCenter)) { + content() + } + } +} + +/** An extra Product Detail button, given the product's ID when clicked. */ +internal class ProductDetailAction( + val label: String, + val testTag: String, + val onClick: (productId: String) -> Unit, +) + +@Composable +internal fun MultiActivityRoute(onOpenProducts: () -> Unit) { + RouteScaffold( + routeSpec = Nav3Route.MultiActivity.routeSpec(), + testTagPrefix = nav3TestTag("route_multi_activity"), + cardContent = { + RouteButton( + "Open products Activity", + onClick = onOpenProducts, + testTag = nav3TestTag("multi_activity_open_products"), + ) + }, + ) +} + +/** + * A self-contained Nav3 root (back stack, [SentryNavEffect], and [NavDisplay]) shared by the + * Multi-Activity and Multi-Fragment samples, so each Activity or Fragment owns its own effect. + * + * @param backStack This root's back stack. Pass one to navigate it from outside the composition. + * @param onCheckout Opens checkout outside this root (e.g., another Activity or Fragment). When + * `null`, checkout is pushed onto this root's own back stack. + * @param productDetailActions Additional Product Detail buttons. Product Detail always offers a + * button that pushes a confirmation after a delay, so the push can happen while this root is + * covered by another one. + * @param onCompleteOrder Handles Checkout's "Complete Order" outside this root (e.g., by returning + * a result). When `null`, the confirmation is pushed onto this root's own back stack. + * @param crashOnFirstComposition Crashes when the Checkout destination's first composition is + * applied, so its navigation transaction and tags can attribute the crash to that screen. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun MultiRootNavDisplay( + activity: ComponentActivity, + title: String, + initialRoute: Nav3Route, + scenario: Nav3Scenario, + configuration: NavigationSampleConfig, + onFinish: () -> Unit, + backStack: SnapshotStateList = rememberSaveableNav3BackStack(initialRoute), + onCheckout: ((String) -> Unit)? = null, + productDetailActions: List = emptyList(), + onCompleteOrder: ((orderId: String) -> Unit)? = null, + crashOnFirstComposition: Boolean = false, +) { + val coroutineScope = rememberCoroutineScope() + var routeWorkOptions by remember { mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN)) } + var showTransactionHistorySheet by remember { mutableStateOf(false) } + var showCrashConfirmation by remember { mutableStateOf(false) } + val options = + remember(configuration) { + SentryNavOptions { + enableNavigationBreadcrumbs = configuration.enableNavigationBreadcrumbs + enableNavigationTransactions = configuration.enableNavigationTransactions + captureBackStack = configuration.captureBackStack + maxCapturedBackStackEntries = configuration.maxCapturedBackStackEntries + } + } + SentryNavEffect( + backStack = backStack, + backStackEntryMapper = { route -> + SentryBackStackEntry(name = route.routeName, arguments = route.arguments) + }, + options = options, + ) + val onBack: () -> Unit = { + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + onFinish() + } + } + Scaffold( + // Expose test tags as resource IDs so adb/uiautomator can target them. + modifier = + Modifier.fillMaxSize().safeDrawingPadding().semantics { testTagsAsResourceId = true }, + topBar = { + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { showTransactionHistorySheet = true }, + modifier = + Modifier.sentryTag(nav3InteractionTag("Recent Transactions")) + .testTag(nav3TestTag("top_bar_recent_transactions")), + ) { + Icon(Icons.Filled.AccountTree, contentDescription = "Recent transactions") + } + IconButton( + onClick = { + showRouteWorkDialog(activity, routeWorkOptions) { routeWorkOptions = it } + }, + modifier = + Modifier.sentryTag(nav3InteractionTag("Route Work Settings")) + .testTag(nav3TestTag("top_bar_route_work_settings")), + ) { + Icon(Icons.Filled.Settings, contentDescription = "Route work settings") + } + } + Text("Current route: ${backStack.last().displayRoute()}") + } + }, + bottomBar = { + SentryControls( + onCaptureException = { captureSampleException("Nav3 ${scenario.label}") }, + onCrashApp = { showCrashConfirmation = true }, + ) + }, + ) { padding -> + // Only handle back while this root is resumed. Otherwise, a root that's still composed but no + // longer current (e.g., a Fragment animating out, or a preloaded ViewPager2 page) could consume + // back presses meant for the current root, popping a back stack the user can't see. + val isResumed = + LocalLifecycleOwner.current.lifecycle + .currentStateAsState() + .value + .isAtLeast(Lifecycle.State.RESUMED) + // Pass the parent explicitly: it's null while this root's view is detached (e.g., a recycled + // ViewPager2 page that still recomposes), where the default parameter would throw. + val parentDispatcherOwner = LocalNavigationEventDispatcherOwner.current + CompositionLocalProvider( + LocalNavigationEventDispatcherOwner provides + rememberNavigationEventDispatcherOwner(enabled = isResumed, parent = parentDispatcherOwner) + ) { + NavDisplay( + backStack = backStack, + modifier = Modifier.fillMaxSize().padding(padding), + onBack = onBack, + entryProvider = + entryProvider { + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductListRoute(backStack) + } + } + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductDetailRoute( + route = route, + backStack = backStack, + onCheckout = { + onCheckout?.invoke(route.productId) + ?: backStack.add(Nav3Route.Checkout(route.productId)) + }, + showOverlays = false, + extraActions = { + RouteButton( + "Push Confirmation in $DELAYED_NAVIGATION_SECONDS s", + onClick = { + coroutineScope.launch { + delay(DELAYED_NAVIGATION_SECONDS * 1_000L) + backStack += Nav3Route.Confirmation("delayed-${route.productId}") + } + }, + testTag = nav3TestTag("product_detail_delayed_confirmation"), + ) + productDetailActions.forEach { action -> + RouteButton( + action.label, + onClick = { action.onClick(route.productId) }, + testTag = action.testTag, + ) + } + }, + ) + } + } + entry { route -> + TracedNav3Route(route, scenario) { + if (crashOnFirstComposition) { + SideEffect { + throw RuntimeException( + "Fatal Nav3 ${scenario.label} crash after first composition" + ) + } + } + Nav3RouteWorkEffect(route, routeWorkOptions) + CheckoutRoute( + route = route, + backStack = backStack, + onCompleteOrder = onCompleteOrder ?: { backStack += Nav3Route.Confirmation(it) }, + ) + } + } + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ConfirmationRoute(route, backStack, rootRoute = initialRoute) + } + } + }, + ) + } + } + if (showCrashConfirmation) { + AlertDialog( + onDismissRequest = { showCrashConfirmation = false }, + title = { Text("Crash app?") }, + text = { Text("This will throw an uncaught exception and close the sample app.") }, + dismissButton = { + TextButton(onClick = { showCrashConfirmation = false }) { Text("Cancel") } + }, + confirmButton = { + TextButton( + onClick = { + showCrashConfirmation = false + crashSampleApp("Nav3 ${scenario.label}") + } + ) { + Text("Crash") + } + }, + ) + } + if (showTransactionHistorySheet) { + NavigationTransactionHistorySheet( + sampleName = "Nav3", + transactions = NavigationTransactionHistory.transactions, + showActivityUiLoadTransactionDelayMessage = configuration.hasOnlyActivityUiLoadTransactions, + onDismissRequest = { showTransactionHistorySheet = false }, + onOpenTransaction = { url -> + activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }, + onDumpTransactionUrl = { url -> + Log.i("Nav3MultiActivity", "Sentry transaction URL: $url") + Toast.makeText(activity, "Dumped transaction URL to logcat.", Toast.LENGTH_SHORT).show() + }, + onCopyTransactionUrl = { url -> + val clipboard = activity.getSystemService(ClipboardManager::class.java) + clipboard.setPrimaryClip(ClipData.newPlainText("Sentry transaction URL", url)) + Toast.makeText(activity, "Copied transaction URL to clipboard.", Toast.LENGTH_SHORT).show() + }, + ) + } +} + +private const val DELAYED_NAVIGATION_SECONDS = 5 +private const val EXTRA_PRODUCT_ID = "io.sentry.samples.android.navigation.nav3.product_id" +private const val EXTRA_RETURN_RESULT = "io.sentry.samples.android.navigation.nav3.return_result" +private const val EXTRA_ORDER_ID = "io.sentry.samples.android.navigation.nav3.order_id" +private const val EXTRA_CRASH_ON_FIRST_COMPOSITION = + "io.sentry.samples.android.navigation.nav3.crash_on_first_composition" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt new file mode 100644 index 00000000000..1ca11284eeb --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt @@ -0,0 +1,420 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp +import androidx.core.graphics.Insets +import androidx.core.os.bundleOf +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.commit +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import io.sentry.samples.android.R +import io.sentry.samples.android.navigation.common.NavigationSampleConfig + +/** + * Hosts Fragments that each own a Nav3 root (back stack, `SentryNavEffect`, and `NavDisplay`), to + * exercise how separate effects hand off during Fragment transactions: + * + * - `replace` + back stack (Products → Checkout), optionally with a slow enter animation; + * - a full-screen `DialogFragment` overlay whose host Fragment stays resumed; + * - a `ViewPager2` of Fragments, where neighboring pages are preloaded at STARTED. + */ +// FragmentActivity rather than AppCompatActivity: AppCompatActivity.setContentView doesn't install +// the NavigationEventDispatcherOwner that NavDisplay needs on the view tree. +class Nav3FragmentsActivity : FragmentActivity() { + + /** When true, Fragment `replace` uses a slow enter animation. */ + internal var slowTransitions by mutableStateOf(false) + + internal val configuration: NavigationSampleConfig + get() = (intent.extras ?: Bundle()).nav3SampleConfiguration() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + + val controls = + ComposeView(this).apply { + setContent { + Nav3SampleTheme { + MultiFragmentControls( + slowTransitions = slowTransitions, + onSlowTransitionsChanged = { slowTransitions = it }, + onOpenCheckoutSheet = ::openCheckoutSheet, + onOpenPager = ::openPager, + ) + } + } + } + val container = FragmentContainerView(this).apply { id = R.id.nav3_multi_fragment_container } + // The controls above already pad for the status bar; keep Fragments from padding for it again. + ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets -> + WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.NONE) + .setInsets(WindowInsetsCompat.Type.displayCutout(), Insets.NONE) + .build() + } + setContentView( + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(controls, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + addView(container, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) + } + ) + + if (savedInstanceState == null) { + supportFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.nav3_multi_fragment_container, Nav3RootFragment.products()) + } + } + } + + internal fun openCheckoutFragment(productId: String) { + supportFragmentManager.commit { + setReorderingAllowed(true) + if (slowTransitions) { + setCustomAnimations( + R.anim.nav3_slow_fade_in, + R.anim.nav3_slow_hold, + R.anim.nav3_slow_fade_in, + R.anim.nav3_slow_hold, + ) + } else { + setCustomAnimations( + android.R.anim.fade_in, + android.R.anim.fade_out, + android.R.anim.fade_in, + android.R.anim.fade_out, + ) + } + replace(R.id.nav3_multi_fragment_container, Nav3RootFragment.checkout(productId)) + addToBackStack("checkout") + } + } + + private fun openPager() { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.nav3_multi_fragment_container, Nav3PagerFragment()) + addToBackStack("pager") + } + } + + private fun openCheckoutSheet() { + Nav3CheckoutSheetFragment().show(supportFragmentManager, "checkout_sheet") + } + + /** Pops this Activity's Fragment back stack, or finishes the Activity when it's empty. */ + internal fun finishRoot() { + if (supportFragmentManager.backStackEntryCount > 0) { + supportFragmentManager.popBackStack() + } else { + finish() + } + } +} + +/** A Fragment that owns one Nav3 root. Pager pages are child Fragments of [Nav3PagerFragment]. */ +class Nav3RootFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val args = requireArguments() + val host = requireNotNull(args.getString(ARG_HOST)) + val activity = requireActivity() as Nav3FragmentsActivity + val initialRoute = + when (args.getString(ARG_KIND)) { + KIND_CHECKOUT -> Nav3Route.Checkout(requireNotNull(args.getString(ARG_PRODUCT_ID))) + KIND_PAGE -> pageRoots[args.getInt(ARG_PAGE)] + else -> Nav3Route.ProductList + } + val opensCheckoutFragment = args.getString(ARG_KIND) == KIND_PRODUCTS + + return nav3RootComposeView( + activity = activity, + host = host, + initialRoute = initialRoute, + configuration = activity.configuration, + onFinish = activity::finishRoot, + onCheckout = if (opensCheckoutFragment) activity::openCheckoutFragment else null, + ) + } + + internal companion object { + private const val ARG_KIND = "kind" + private const val ARG_HOST = "host" + private const val ARG_PRODUCT_ID = "product_id" + private const val ARG_PAGE = "page" + private const val KIND_PRODUCTS = "products" + private const val KIND_CHECKOUT = "checkout" + private const val KIND_PAGE = "page" + + internal val pageRoots: List = + listOf(Nav3Route.ProductList, Nav3Route.Checkout("7"), Nav3Route.Confirmation("page-2")) + + fun products() = + Nav3RootFragment().apply { + arguments = bundleOf(ARG_KIND to KIND_PRODUCTS, ARG_HOST to "ProductsFragment") + } + + fun checkout(productId: String) = + Nav3RootFragment().apply { + arguments = + bundleOf( + ARG_KIND to KIND_CHECKOUT, + ARG_HOST to "CheckoutFragment", + ARG_PRODUCT_ID to productId, + ) + } + + fun page(index: Int) = + Nav3RootFragment().apply { + arguments = bundleOf(ARG_KIND to KIND_PAGE, ARG_HOST to "Page$index", ARG_PAGE to index) + } + } +} + +/** A full-screen dialog with its own Nav3 root. The Fragment underneath stays resumed. */ +class Nav3CheckoutSheetFragment : DialogFragment() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, R.style.AppTheme_Main) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val activity = requireActivity() as Nav3FragmentsActivity + return nav3RootComposeView( + activity = activity, + host = "CheckoutSheet", + initialRoute = Nav3Route.Checkout("42"), + configuration = activity.configuration, + onFinish = ::dismiss, + onCheckout = null, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout(MATCH_PARENT, MATCH_PARENT) + } +} + +/** + * A `ViewPager2` whose pages each own a Nav3 root. With `offscreenPageLimit = 1`, the neighbors of + * the current page are created and capped at STARTED, while only the current page is RESUMED. + */ +class Nav3PagerFragment : Fragment() { + + private var currentPage by mutableIntStateOf(0) + private var pager: ViewPager2? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val viewPager = + ViewPager2(requireContext()).apply { + id = R.id.nav3_multi_fragment_pager + offscreenPageLimit = 1 + } + pager = viewPager + val header = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + Nav3SampleTheme { + PagerHeader(currentPage = currentPage, onSelectPage = { viewPager.currentItem = it }) + } + } + } + return LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + addView(header, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + addView(viewPager, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val viewPager = requireNotNull(pager) + viewPager.adapter = + object : FragmentStateAdapter(childFragmentManager, viewLifecycleOwner.lifecycle) { + override fun getItemCount(): Int = Nav3RootFragment.pageRoots.size + + override fun createFragment(position: Int): Fragment = Nav3RootFragment.page(position) + } + viewPager.registerOnPageChangeCallback( + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + currentPage = position + } + } + ) + } + + override fun onDestroyView() { + pager = null + super.onDestroyView() + } +} + +@Composable +internal fun MultiFragmentRoute(onOpenFragments: () -> Unit) { + RouteScaffold( + routeSpec = Nav3Route.MultiFragment.routeSpec(), + testTagPrefix = nav3TestTag("route_multi_fragment"), + cardContent = { + RouteButton( + "Open fragments Activity", + onClick = onOpenFragments, + testTag = nav3TestTag("multi_fragment_open_fragments"), + ) + }, + ) +} + +private fun Fragment.nav3RootComposeView( + activity: ComponentActivity, + host: String, + initialRoute: Nav3Route, + configuration: NavigationSampleConfig, + onFinish: () -> Unit, + onCheckout: ((String) -> Unit)?, +): ComposeView = + ComposeView(requireContext()).apply { + // Dispose with the Fragment's view, so the root's SentryNavEffect leaves the composition when + // the Fragment's view is destroyed (e.g., when it's replaced and put on the back stack). + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + Nav3SampleTheme { + CompositionLocalProvider(LocalNav3SampleHost provides host) { + MultiRootNavDisplay( + activity = activity, + title = host, + initialRoute = initialRoute, + scenario = Nav3Scenario.MULTI_FRAGMENT, + configuration = configuration, + onFinish = onFinish, + onCheckout = onCheckout, + ) + } + } + } + } + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun MultiFragmentControls( + slowTransitions: Boolean, + onSlowTransitionsChanged: (Boolean) -> Unit, + onOpenCheckoutSheet: () -> Unit, + onOpenPager: () -> Unit, +) { + Surface( + shadowElevation = 4.dp, + modifier = Modifier.statusBarsPadding().semantics { testTagsAsResourceId = true }, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text("Fragments Activity", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onOpenCheckoutSheet, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_open_checkout_sheet")), + ) { + Text("Checkout sheet") + } + OutlinedButton( + onClick = onOpenPager, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_open_pager")), + ) { + Text("Pager") + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Slow Fragment transitions", modifier = Modifier.weight(1f)) + Switch( + checked = slowTransitions, + onCheckedChange = onSlowTransitionsChanged, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_slow_transitions")), + ) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun PagerHeader(currentPage: Int, onSelectPage: (Int) -> Unit) { + Column( + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).semantics { + testTagsAsResourceId = true + } + ) { + Text( + "ViewPager2 page $currentPage (neighbors preloaded at STARTED)", + modifier = Modifier.testTag(nav3TestTag("multi_fragment_current_page")), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Nav3RootFragment.pageRoots.indices.forEach { index -> + OutlinedButton( + onClick = { onSelectPage(index) }, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_page_$index")), + ) { + Text("Page $index") + } + } + } + } +} + +private const val MATCH_PARENT = ViewGroup.LayoutParams.MATCH_PARENT +private const val WRAP_CONTENT = ViewGroup.LayoutParams.WRAP_CONTENT diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt index 1d4b933fdc3..314d4274568 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt @@ -28,25 +28,30 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag 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 io.sentry.ITransaction import io.sentry.Sentry import io.sentry.compose.SentryModifier.sentryTag import io.sentry.compose.SentryTraced import io.sentry.samples.android.R +import io.sentry.samples.android.getActivity import io.sentry.samples.android.navigation.common.RouteNames import io.sentry.samples.android.navigation.common.RouteSpec import io.sentry.samples.android.navigation.common.RouteSpecs @@ -58,6 +63,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import retrofit2.HttpException +/** + * Names the Activity or Fragment hosting the current Nav3 root, for the sample's transaction tags. + * Defaults to the Activity's class name when unset. + */ +internal val LocalNav3SampleHost = staticCompositionLocalOf { null } + @ExperimentalComposeUiApi @Composable internal fun TracedNav3Route( @@ -65,7 +76,10 @@ internal fun TracedNav3Route( scenario: Nav3Scenario, content: @Composable BoxScope.() -> Unit, ) { - tagCurrentNav3Scenario(scenario) + val activityName = LocalContext.current.getActivity().javaClass.simpleName + val hostName = LocalNav3SampleHost.current ?: activityName + // Tag after composition is applied so abandoned compositions don't tag anything. + SideEffect { tagCurrentNav3Scenario(route, scenario, hostName, activityName) } SentryTraced( tag = "Nav3 /${route.routeName}", enableUserInteractionTracing = false, @@ -73,9 +87,52 @@ internal fun TracedNav3Route( ) } -private fun tagCurrentNav3Scenario(scenario: Nav3Scenario) { - Sentry.getSpan()?.setTag("sample_nav3_scenario", scenario.label) - tagCurrentNavigationSampleScenario(scenario.label) +private fun tagCurrentNav3Scenario( + route: Nav3Route, + scenario: Nav3Scenario, + hostName: String, + activityName: String, +) { + val sessionId = NavigationTransactionHistory.sessionId + Sentry.configureScope { scope -> + scope.withTransaction { transaction -> + tagNavigationSampleTransaction( + transaction, + route, + scenario, + hostName, + activityName, + sessionId, + ) + } + } +} + +private fun tagNavigationSampleTransaction( + transaction: ITransaction?, + route: Nav3Route, + scenario: Nav3Scenario, + hostName: String, + activityName: String, + sessionId: String, +) { + // A covered root can remain composed and re-run this SideEffect after another root acquires the + // navigation lease. Only tag this destination's transaction, or an untagged ui.load transaction + // owned by its Activity. + val activeTransaction = transaction ?: return + val ownsNavigationTransaction = activeTransaction.name == "/${route.routeName}" + val ownsUiLoadTransaction = + activeTransaction.operation == ACTIVITY_UI_LOAD_OP && + activeTransaction.name == activityName && + activeTransaction.getTag(NAVIGATION_SAMPLE_ACTIVITY_TAG) == null + if (!ownsNavigationTransaction && !ownsUiLoadTransaction) { + return + } + + activeTransaction.setTag(NAV3_SCENARIO_TAG, scenario.label) + activeTransaction.setTag(NAVIGATION_SAMPLE_SCENARIO_TAG, scenario.label) + activeTransaction.setTag(NAVIGATION_SAMPLE_ACTIVITY_TAG, hostName) + activeTransaction.setTag(NAVIGATION_SAMPLE_SESSION_TAG, sessionId) } @Composable @@ -496,6 +553,9 @@ internal fun ProductListRoute(backStack: SnapshotStateList) { internal fun ProductDetailRoute( route: Nav3Route.ProductDetail, backStack: SnapshotStateList, + onCheckout: () -> Unit = { backStack.add(Nav3Route.Checkout(route.productId)) }, + showOverlays: Boolean = true, + extraActions: @Composable ColumnScope.() -> Unit = {}, ) { LaunchedEffect(route.productId, route.source, route.campaign) { recordSimulatedBackgroundSpan(RouteNames.PRODUCT_DETAIL) @@ -508,21 +568,24 @@ internal fun ProductDetailRoute( RouteSpecs.productDetail.displayArguments(route.arguments).forEach { (label, value) -> RouteInfo(label, value) } - RouteButton( - "Show Promo Dialog", - onClick = { backStack.add(Nav3Route.PromoDialog("detail-${route.productId}")) }, - testTag = nav3TestTag("product_detail_show_promo_dialog"), - ) - RouteButton( - "Open Share Sheet", - onClick = { backStack.add(Nav3Route.ShareSheet(route.productId)) }, - testTag = nav3TestTag("product_detail_open_share_sheet"), - ) + if (showOverlays) { + RouteButton( + "Show Promo Dialog", + onClick = { backStack.add(Nav3Route.PromoDialog("detail-${route.productId}")) }, + testTag = nav3TestTag("product_detail_show_promo_dialog"), + ) + RouteButton( + "Open Share Sheet", + onClick = { backStack.add(Nav3Route.ShareSheet(route.productId)) }, + testTag = nav3TestTag("product_detail_open_share_sheet"), + ) + } RouteButton( "Go to Checkout", - onClick = { backStack.add(Nav3Route.Checkout(route.productId)) }, + onClick = onCheckout, testTag = nav3TestTag("product_detail_go_to_checkout"), ) + extraActions() }, footerContent = { RouteButton( @@ -538,6 +601,7 @@ internal fun ProductDetailRoute( internal fun CheckoutRoute( route: Nav3Route.Checkout, backStack: SnapshotStateList, + onCompleteOrder: (orderId: String) -> Unit = { backStack.add(Nav3Route.Confirmation(it)) }, ) { RouteScaffold( routeSpec = RouteSpecs.checkout, @@ -548,7 +612,7 @@ internal fun CheckoutRoute( } RouteButton( "Complete Order", - onClick = { backStack.add(Nav3Route.Confirmation(orderId = "order-${route.productId}")) }, + onClick = { onCompleteOrder("order-${route.productId}") }, testTag = nav3TestTag("checkout_complete_order"), ) }, diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt index b9972a6cd76..554a5d3c3d3 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt @@ -26,6 +26,22 @@ internal fun Nav3Route.routeSpec(): RouteSpec = "Reuses the single-stack shopping flow while simulating a power user who starts their " + "own manual transactions.", ) + Nav3Route.MultiActivity -> + RouteSpec( + routeName = Nav3Route.MultiActivity.routeName, + title = "Multi-Activity", + description = + "Tests how SentryNavEffect uses NavScopes to coordinate state updates between " + + "SentryNavEffect instances. Each Activity owns its own SentryNavEffect and NavDisplay.", + ) + Nav3Route.MultiFragment -> + RouteSpec( + routeName = Nav3Route.MultiFragment.routeName, + title = "Multi-Fragment", + description = + "Tests SentryNavEffect when each Fragment owns its own SentryNavEffect and NavDisplay: " + + "Fragment replace transitions, a dialog overlay, and a ViewPager2 of Fragments.", + ) Nav3Route.DeepLink -> RouteSpec( routeName = Nav3Route.DeepLink.routeName, @@ -69,6 +85,8 @@ private fun Nav3Route.toSavedState(): Bundle = Nav3Route.Landing -> putString("type", "landing") Nav3Route.SingleStack -> putString("type", "single_stack") Nav3Route.Custom -> putString("type", "custom") + Nav3Route.MultiActivity -> putString("type", "multi_activity") + Nav3Route.MultiFragment -> putString("type", "multi_fragment") Nav3Route.DeepLink -> putString("type", "deep_link") Nav3Route.ProductList -> putString("type", "product_list") is Nav3Route.ProductDetail -> { @@ -108,6 +126,8 @@ private fun Bundle.toNav3Route(): Nav3Route { "landing" -> Nav3Route.Landing "single_stack" -> Nav3Route.SingleStack "custom" -> Nav3Route.Custom + "multi_activity" -> Nav3Route.MultiActivity + "multi_fragment" -> Nav3Route.MultiFragment "deep_link" -> Nav3Route.DeepLink "product_list" -> Nav3Route.ProductList "product_detail" -> { @@ -148,6 +168,8 @@ internal fun SnapshotStateList.openScenario(scenario: Nav3Scenario) { Nav3Scenario.LANDING -> resetTo(Nav3Route.Landing) Nav3Scenario.SINGLE_STACK -> resetTo(Nav3Route.SingleStack) Nav3Scenario.CUSTOM -> resetTo(Nav3Route.Custom) + Nav3Scenario.MULTI_ACTIVITY -> resetTo(Nav3Route.MultiActivity) + Nav3Scenario.MULTI_FRAGMENT -> resetTo(Nav3Route.MultiFragment) Nav3Scenario.DEEP_LINK -> resetTo(Nav3Route.DeepLink) Nav3Scenario.MULTIPANE -> resetTo(Nav3Route.Multipane) Nav3Scenario.MULTIPLE_STACKS -> resetTo(Nav3Route.Multistack) @@ -211,6 +233,14 @@ internal sealed interface Nav3Route { override val routeName: String = "Custom" } + data object MultiActivity : Nav3Route { + override val routeName: String = "MultiActivity" + } + + data object MultiFragment : Nav3Route { + override val routeName: String = "MultiFragment" + } + data object DeepLink : Nav3Route { override val routeName: String = RouteNames.DEEP_LINK } @@ -282,6 +312,8 @@ internal enum class Nav3Scenario(val label: String, val showTab: Boolean = true) SINGLE_STACK("Single Stack"), DEEP_LINK("Deep Link"), CUSTOM("Custom"), + MULTI_ACTIVITY("Multi-Activity"), + MULTI_FRAGMENT("Multi-Fragment"), MULTIPANE("Multipane"), MULTIPLE_STACKS("Multistack"), PERFORMANCE("Performance"), @@ -293,6 +325,8 @@ internal val Nav3Scenario.initialRoute: Nav3Route Nav3Scenario.LANDING -> Nav3Route.Landing Nav3Scenario.SINGLE_STACK -> Nav3Route.SingleStack Nav3Scenario.CUSTOM -> Nav3Route.Custom + Nav3Scenario.MULTI_ACTIVITY -> Nav3Route.MultiActivity + Nav3Scenario.MULTI_FRAGMENT -> Nav3Route.MultiFragment Nav3Scenario.DEEP_LINK -> Nav3Route.DeepLink Nav3Scenario.MULTIPANE -> Nav3Route.Multipane Nav3Scenario.MULTIPLE_STACKS -> Nav3Route.Multistack diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt index 4a9b77227b1..a23ed37c3f6 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt @@ -73,7 +73,9 @@ internal const val SENTRY_FLUSH_TIMEOUT_MILLIS = 5000L private const val BACKGROUND_WORK_MILLIS = 1000L private const val NAV2_SCENARIO_TAG = "sample_nav2_scenario" -private const val NAV3_SCENARIO_TAG = "sample_nav3_scenario" -private const val NAVIGATION_SAMPLE_SCENARIO_TAG = "sample_navigation_scenario" +internal const val NAV3_SCENARIO_TAG = "sample_nav3_scenario" +internal const val NAVIGATION_SAMPLE_ACTIVITY_TAG = "sample_navigation_activity" +internal const val NAVIGATION_SAMPLE_SESSION_TAG = "sample_navigation_session" +internal const val NAVIGATION_SAMPLE_SCENARIO_TAG = "sample_navigation_scenario" private const val UNKNOWN_NAVIGATION_SCENARIO_LABEL = "Unknown" -private const val ACTIVITY_UI_LOAD_OP = "ui.load" +internal const val ACTIVITY_UI_LOAD_OP = "ui.load" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt index db945d45198..7b37bbeacab 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt @@ -35,6 +35,7 @@ class Nav3SetupActivity : AppCompatActivity() { onLaunch = { val previousOptions = currentNavigationSampleConfigSnapshot() configuration.applyToCurrentOptions() + NavigationTransactionHistory.startSession() startActivity(nav3LaunchIntent(configuration, previousOptions)) }, ) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt index 38cd6d2039b..21d99a50b2c 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt @@ -8,12 +8,19 @@ import io.sentry.protocol.SentrySpan import io.sentry.protocol.SentryTransaction import io.sentry.samples.android.SampleBeforeSendTransactionHook -/** State holder backing the navigation sample transaction history sheet. */ -internal class NavigationTransactionHistory(private val isActive: () -> Boolean) { +/** Process-wide state holder backing every Nav3 transaction history sheet. */ +internal object NavigationTransactionHistory { - val transactions = mutableStateListOf() + private val store = NavigationTransactionHistoryStore() + @Volatile private var sessionGeneration = 0L + + val transactions: List + get() = store.transactions + + val sessionId: String + get() = sessionGeneration.toString() - private val mainHandler = Handler(Looper.getMainLooper()) + private val mainHandler by lazy { Handler(Looper.getMainLooper()) } private val transactionListener: (SentryTransaction, String?) -> Unit = { transaction, dsn -> add(transaction, dsn) } @@ -24,34 +31,56 @@ internal class NavigationTransactionHistory(private val isActive: () -> Boolean) SampleBeforeSendTransactionHook.addListener(transactionListener) } - fun uninstall() { + fun startSession() { + install() + sessionGeneration++ clear() - SampleBeforeSendTransactionHook.removeListener(transactionListener) } fun clear() { - transactions.clear() + store.clear() } private fun add(transaction: SentryTransaction, dsn: String?) { - if (!isActive()) { + val transactionSessionId = transaction.getTag(NAVIGATION_SAMPLE_SESSION_TAG) + if (transaction.getTag(NAV3_SCENARIO_TAG) == null || transactionSessionId != sessionId) { return } val trace = transaction.toNavigationTransactionTrace(dsn) if (Looper.myLooper() == Looper.getMainLooper()) { - transactions.addMostRecent(trace) + store.add(trace) } else { mainHandler.post { - if (isActive()) { - transactions.addMostRecent(trace) + // The session may have changed while this was queued. + if (transactionSessionId == sessionId) { + store.add(trace) } } } } } +internal class NavigationTransactionHistoryStore( + private val transactionLimit: Int = TRANSACTION_HISTORY_LIMIT +) { + + val transactions = mutableStateListOf() + + fun add(transaction: NavigationTransactionTrace) { + transactions.add(0, transaction) + while (transactions.size > transactionLimit) { + transactions.removeAt(transactions.lastIndex) + } + } + + fun clear() { + transactions.clear() + } +} + internal data class NavigationTransactionTrace( + val activity: String, val name: String, val operation: String, val eventId: String, @@ -73,7 +102,7 @@ internal data class NavigationTraceSpan( val children: List = emptyList(), ) -private fun SentryTransaction.toNavigationTransactionTrace( +internal fun SentryTransaction.toNavigationTransactionTrace( dsn: String? ): NavigationTransactionTrace { val trace = contexts.trace @@ -97,6 +126,7 @@ private fun SentryTransaction.toNavigationTransactionTrace( .sortedBy { span -> span.startOffsetMillis } return NavigationTransactionTrace( + activity = getTag(NAVIGATION_SAMPLE_ACTIVITY_TAG) ?: UNKNOWN_NAVIGATION_ACTIVITY, name = transaction ?: "", operation = trace?.operation ?: "transaction", eventId = eventId, @@ -109,15 +139,6 @@ private fun SentryTransaction.toNavigationTransactionTrace( ) } -private fun MutableList.addMostRecent( - transaction: NavigationTransactionTrace -) { - add(0, transaction) - while (size > TRANSACTION_HISTORY_LIMIT) { - removeAt(lastIndex) - } -} - private fun SentrySpan.toNavigationTraceSpan( transactionStartTimestamp: Double ): NavigationTraceSpan = @@ -172,5 +193,6 @@ private fun String.projectIdFromDsn(): String? = projectId.isNotEmpty() && projectId.all { it.isDigit() } } -private const val TRANSACTION_HISTORY_LIMIT = 10 +internal const val TRANSACTION_HISTORY_LIMIT = 25 private const val SENTRY_SAMPLE_ORG_SLUG = "sentry-sdks" +private const val UNKNOWN_NAVIGATION_ACTIVITY = "Unknown" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt index 0e0038083ea..94a643ba7ca 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt @@ -66,7 +66,8 @@ internal fun NavigationTransactionHistorySheet( modifier = Modifier.testTag(historyTag(sampleName, "title")), ) Text( - "Displays the last 10 finished transactions emitted by the SDK. Transactions with child work appear here after their work finishes.", + "Displays the last $TRANSACTION_HISTORY_LIMIT finished Nav3 transactions emitted by the SDK. " + + "Transactions with child work appear here after their work finishes.", style = MaterialTheme.typography.bodyMedium, ) Column( @@ -133,7 +134,11 @@ private fun TransactionCard( modifier = Modifier.padding(12.dp).testTag(historyTag(sampleName, "card_${index}_content")), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { Column( modifier = Modifier.weight(1f).testTag(historyTag(sampleName, "card_${index}_summary")) ) { @@ -152,12 +157,20 @@ private fun TransactionCard( modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_operation")), ) } - Text( - "Tab: ${transaction.tab}", - style = MaterialTheme.typography.labelLarge, - fontFamily = FontFamily.Monospace, - modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_tab")), - ) + Column(horizontalAlignment = Alignment.End) { + Text( + transaction.activity, + style = MaterialTheme.typography.labelLarge, + fontFamily = FontFamily.Monospace, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_activity")), + ) + Text( + "Tab: ${transaction.tab}", + style = MaterialTheme.typography.labelLarge, + fontFamily = FontFamily.Monospace, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_tab")), + ) + } } TransactionWaterfall(transaction, historyTag(sampleName, "card_${index}_waterfall")) diff --git a/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml new file mode 100644 index 00000000000..a74b152b33c --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml @@ -0,0 +1,7 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml new file mode 100644 index 00000000000..8928cea2bf4 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml @@ -0,0 +1,6 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml b/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml index 918cb756c54..a87670f6c0d 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml @@ -23,4 +23,7 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml b/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml index b01417199a8..73f92d1d263 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml @@ -20,6 +20,12 @@ + + +