From bd1441d2aef37dfe0ee38f65b3ed1bc0485279b5 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 2 Oct 2026 12:06:24 +0200 Subject: [PATCH] ref(android): Support updates to nav state across multiple SentryNavEffect instances (JAVA-274) Allow separate SentryNavEffect instances to overlap during Activity transitions without stale cleanup clobbering the active observer. --- gradle/libs.versions.toml | 2 + sentry-android-navigation3/build.gradle.kts | 1 + .../compose/navigation3/BackStackObserver.kt | 3 +- .../sentry/compose/navigation3/INavScopes.kt | 541 +++++++++++ .../sentry/compose/navigation3/NavSession.kt | 49 + .../compose/navigation3/SentryNavEffect.kt | 36 +- .../compose/navigation3/INavScopesTest.kt | 843 ++++++++++++++++++ .../navigation3/NavLeaseCoordinatorTest.kt | 163 ++++ .../compose/navigation3/NavSessionTest.kt | 281 ++++++ .../compose/navigation3/NavTestFixture.kt | 74 ++ .../navigation3/SentryNavEffectTest.kt | 82 ++ .../src/main/AndroidManifest.xml | 12 + .../android/navigation/nav3/Nav3Activity.kt | 11 + .../navigation/nav3/Nav3MultiActivity.kt | 281 ++++++ .../navigation/nav3/Nav3RouteScreens.kt | 26 +- .../android/navigation/nav3/Nav3Routes.kt | 17 + 16 files changed, 2397 insertions(+), 25 deletions(-) create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/INavScopes.kt create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/NavSession.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/INavScopesTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavLeaseCoordinatorTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavSessionTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavTestFixture.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e9ddb0bd9..fcb21228d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ animalsniffer = "2.0.1" apollo = "2.5.9" androidxLifecycle = "2.2.0" +androidxLifecycleCompose = "2.8.7" # Nav3 needs LocalLifecycleOwner (2.8+); keep Android core's runtime baseline separate. androidxNavigation = "2.9.8" androidxNavigation3 = "1.1.5" androidxTestCore = "1.7.0" @@ -106,6 +107,7 @@ androidx-core = { module = "androidx.core:core", version = "1.3.2" } androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.3.5" } androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidxLifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycleCompose" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index da7ad9f36f..1fbbeb4bde 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(projects.sentry) compileOnly(libs.androidx.compose.runtime) + compileOnly(libs.androidx.lifecycle.runtime.compose) testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.test.core) diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt index 9b687d0b25..682aa45d00 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt @@ -21,6 +21,8 @@ import io.sentry.protocol.TransactionNameSource import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import java.lang.ref.WeakReference +internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation" + private const val NAVIGATION_OP: String = "navigation" /** @@ -374,7 +376,6 @@ private class NavContext(private val scopes: IScopes, private val options: Sentr private companion object { private const val BACKSTACK_KEY = "Back Stack" - private const val NAVIGATION_CONTEXT_KEY = "Navigation" } fun update(scope: IScope, backStackEntries: List) { diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/INavScopes.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/INavScopes.kt new file mode 100644 index 0000000000..c4413a7da6 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/INavScopes.kt @@ -0,0 +1,541 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.NoOpTransaction +import io.sentry.PropagationContext +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.ScopeType +import io.sentry.SentryOptions +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions + +/** + * An [IScopes] implementation that lets us coordinate updates to navigation state across multiple + * [SentryNavEffect]s. (Also supports updates managed by a single `SentryNavEffect`.) + * + * Needed because all `SentryNavEffect`s mutate a single [IScope] instance by default. + * + * **Limitations** + * + * Multi-effect support is limited to scenarios where: + * + * - each `SentryNavEffect` is managed by a single [LifecycleOwner] (e.g., a multi-Activity app with + * one `SentryNavEffect` per Activity); + * + * - in general only one `SentryNavEffect` is active at a time; but + * + * - brief windows exist where multiple `SentryNavEffect`s may be simultaneously active (e.g., as + * the host app switches between activities) + * + * Does ***not*** support scenarios where multiple `SentryNavEffect`s are meant to be simultaneously + * active, whether as siblings or nested. + * + * **Only leaseholders generate nav transactions or data** + * + * This class works by maintaining separate nav transaction and nav data leases, and only allowing + * `INavScopes` instances holding the relevant lease to mutate the underlying `IScope`. + * + * `NavIScope` instances without a lease write to a [stagedNavState], which lets them preserve their + * latest nav updates without overwriting another instance's published data. When an instance + * acquires the lease, it publishes its staged screen and navigation context to the underlying + * [IScope], clearing either field that it has not staged so the previous owner's data cannot linger + * after the handoff. Published data remains in the scope while no instance holds the lease, keeping + * navigation context available to crash events during lifecycle gaps. + * + * The data lease is acquired automatically as a `LifecycleOwner` transitions into + * [resumed][Lifecycle.Event.ON_RESUME] state, and released automatically as it transitions into + * [paused][Lifecycle.Event.ON_PAUSE] state. + * + * The transaction lease is acquired during [attach] and in [ON_CREATE][Lifecycle.Event.ON_CREATE] + * in order to eagerly create a nav transaction for the initial nav destination (otherwise initial + * spans may be dropped or misattributed). The transaction lease is released upon + * [disposal][dispose], or whenever another `INavScopes` instance claims ownership. + * + * **Usage** + * + * 1. Create a `INavScopes` instance per `SentryNavEffect`. + * + * 2. Pass the same [NavLeaseCoordinator] instance to all `INavScopes` whose effects must be + * coordinated (i.e., that target the same `IScope`). + * + * 3. Call [attach] with the lifecycle that manages the effect before the first back stack update. + * + * 4. Route back stack updates through [runBackStackUpdate]. + * + * 5. Call [dispose] when the effect leaves the composition. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. Lifecycle callbacks and method invocations should be + * confined to the same thread. + */ +internal class INavScopes( + private val delegate: IScopes, + private val coordinator: NavLeaseCoordinator = defaultNavLeaseCoordinator, +) : IScopes by delegate, LifecycleEventObserver { + + private val owner: Any = Any() + private var attachedLifecycle: Lifecycle? = null + private val stagedNavState = StagedNavState() + + /** + * Whether [runBackStackUpdate] is currently executing its callback. + * + * While true, [configureScope] prepares transaction handoff before applying scope updates if this + * [INavScopes] instance owns the transaction lease. Ordinary scope operations, including observer + * cleanup, do not trigger that handoff. + */ + private var isProcessingBackStackUpdate: Boolean = false + + /** + * Attaches this [INavScopes] instance to [lifecycle] and claims any applicable leases. No-ops if + * `lifecycle` has already been attached or is [destroyed][Lifecycle.Event.ON_DESTROY]. + * + * Throws if a caller tries to attach this instance to more than one lifecycle. + */ + fun attach(lifecycle: Lifecycle) { + check(attachedLifecycle == null || attachedLifecycle === lifecycle) { + "INavScopes has already been attached to a different lifecycle!" + } + + if (attachedLifecycle === lifecycle) { + return + } + if (lifecycle.currentState == Lifecycle.State.DESTROYED) { + return + } + + attachedLifecycle = lifecycle + + coordinator.claimTransactionOwnership(owner) + if (lifecycle.currentState == Lifecycle.State.RESUMED) { + claimDataOwnership() + } + + lifecycle.addObserver(this) + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_CREATE -> coordinator.claimTransactionOwnership(owner) + Lifecycle.Event.ON_RESUME -> claimDataOwnership() + Lifecycle.Event.ON_PAUSE -> releaseDataOwnership() + Lifecycle.Event.ON_DESTROY -> dispose() + else -> Unit + } + } + + /** Runs [block] as a back stack update eligible to replace the previous owner's transaction. */ + fun runBackStackUpdate(block: () -> Unit) { + isProcessingBackStackUpdate = true + try { + block() + } finally { + isProcessingBackStackUpdate = false + } + } + + /** Releases the leases held by this [INavScopes] instance and any transaction it still owns. */ + fun dispose() { + attachedLifecycle?.removeObserver(this) + attachedLifecycle = null + + releaseDataOwnership() + + delegate.configureScope { scope -> + coordinator.stopOwnedTransaction(owner, scope) + coordinator.releaseTransactionOwnership(owner) + } + } + + override fun configureScope(scopeType: ScopeType?, callback: ScopeCallback) { + val leasedCallback = ScopeCallback { scope -> + if (isProcessingBackStackUpdate) { + coordinator.prepareTransactionUpdate(owner, scope) + } + callback.run(LeasedNavigationScope(scope)) + coordinator.clearFinishedTransaction() + } + + if (scopeType == null) { + delegate.configureScope(leasedCallback) + } else { + delegate.configureScope(scopeType, leasedCallback) + } + } + + override fun startTransaction( + transactionContext: TransactionContext, + transactionOptions: TransactionOptions, + ): ITransaction { + if (!coordinator.ownsTransactions(owner)) { + return NoOpTransaction.getInstance() + } + + return delegate.startTransaction(transactionContext, transactionOptions).also { transaction -> + if (!transaction.isNoOp) { + coordinator.bindTransaction(owner, transaction) + } + } + } + + override fun addBreadcrumb(breadcrumb: Breadcrumb, hint: Hint?) { + stagedNavState.addBreadcrumb(coordinator.ownsData(owner)) { + delegate.addBreadcrumb(breadcrumb, hint) + } + } + + override fun addBreadcrumb(breadcrumb: Breadcrumb) { + stagedNavState.addBreadcrumb(coordinator.ownsData(owner)) { delegate.addBreadcrumb(breadcrumb) } + } + + /** + * Claims the data lease for this instance, replacing the previous owner. + * + * Publishes the staged screen and navigation context, clearing either field with no staged + * update. Also publishes the latest breadcrumb buffered before this instance's first publication. + * + * No-ops if this instance already holds the lease. + */ + private fun claimDataOwnership() { + if (!coordinator.claimDataOwnership(owner)) { + return + } + + delegate.configureScope { scope -> + if (!coordinator.ownsData(owner)) { + return@configureScope + } + stagedNavState.publishTo(scope) + } + + stagedNavState.publishPendingBreadcrumb(coordinator.ownsData(owner)) + } + + /** + * Releases the data lease held by this instance. + * + * No-ops if this instance doesn't hold the lease. + */ + private fun releaseDataOwnership() { + coordinator.releaseDataOwnership(owner) + } + + private inner class LeasedNavigationScope(private val realScope: IScope) : IScope by realScope { + + override fun getScreen(): String? = + stagedNavState.getScreen(realScope, coordinator.ownsData(owner)) + + override fun setScreen(screen: String?) { + stagedNavState.setScreen(screen, realScope, coordinator.ownsData(owner)) + } + + override fun setContexts(key: String?, value: Any?) { + if (key != NAVIGATION_CONTEXT_KEY) { + realScope.setContexts(key, value) + return + } + + stagedNavState.setNavigationContext(value, realScope, coordinator.ownsData(owner)) + } + + override fun getPropagationContext(): PropagationContext = + if (coordinator.ownsTransactions(owner)) { + realScope.propagationContext + } else { + stagedNavState.propagationContext + } + + override fun setPropagationContext(propagationContext: PropagationContext) { + if (coordinator.ownsTransactions(owner)) { + realScope.propagationContext = propagationContext + } else { + stagedNavState.propagationContext = propagationContext + } + } + + override fun withPropagationContext( + callback: Scope.IWithPropagationContext + ): PropagationContext = + if (coordinator.ownsTransactions(owner)) { + realScope.withPropagationContext(callback) + } else { + stagedNavState.withPropagationContext(callback) + } + } +} + +/** + * Holds navigation data staged for publication while an [INavScopes] instance lacks the + * corresponding data lease. + * + * Also implements a breadcrumb buffering policy that: + * + * 1. tracks the latest breadcrumb received prior to initial data lease acquisition; + * + * 2. publishes that breadcrumb upon initial lease acquisition; and + * + * 3. discards all breadcrumbs received after its first breadcrumb has been published when the data + * lease isn't held. + * + * That policy gives us the following behavior: + * + * - Suppose a [SentryNavEffect] receives its initial back stack update before its + * [LifecycleOwner]'s state reaches [resumed][Lifecycle.Event.ON_RESUME], so it can't publish its + * breadcrumb yet. If the effect receives a `Home` and then a `Profile` back stack entry during + * that time, buffering the latest breadcrumb lets us publish only `Profile` when the lease is + * acquired, which matches what the user actually sees (rather than publishing both `Home` and + * `Profile`). + * + * - Suppose the `SentryNavEffect` that publishes `Profile` pauses and loses the data lease. Another + * effect takes over and records a `Settings` back stack entry. If the paused effect buffers new + * breadcrumbs while it lacks the lease, it might later replay (an unseen) `Checkout` when it + * resumes, even though `Settings` was already reported by the active effect. Discarding those + * later breadcrumbs avoids replaying history that may be stale or out of order. + */ +private class StagedNavState { + + private val scope = Scope(SentryOptions.empty()) + + /** + * Whether this instance has received a screen update, including an explicit clear. + * + * This determines whether [publishTo] should publish the staged screen or clear a previous + * leaseholder's screen. A null staged value alone cannot distinguish an explicit clear from a + * screen that has never been updated. + */ + private var hasScreenUpdate = false + + /** + * Whether this instance has received a navigation context update, including an explicit clear. + * + * This determines whether [publishTo] should publish the staged context or clear a previous + * leaseholder's context, even if the staged scope no longer contains the key. + */ + private var hasNavigationContextUpdate = false + + /** + * The latest breadcrumb received without the data lease before the first publication. + * + * A newer breadcrumb replaces the pending one. It is invoked and cleared when this instance + * acquires the lease. + */ + private var pendingInitialBreadcrumb: (() -> Unit)? = null + + /** + * Whether this instance has ever published a breadcrumb. + * + * This remains true across pauses, so breadcrumbs received without the data lease after the first + * publication are discarded rather than replayed as stale navigation history on resume. + */ + private var hasPublishedBreadcrumb: Boolean = false + + private val screenName: String? + get() = scope.screen + + private val contexts + get() = scope.contexts + + var propagationContext: PropagationContext + get() = scope.propagationContext + set(value) { + scope.propagationContext = value + } + + fun getScreen(realScope: IScope, ownsDataLease: Boolean): String? = + if (ownsDataLease) realScope.screen else screenName + + fun setScreen(screen: String?, realScope: IScope, ownsDataLease: Boolean) { + scope.screen = screen + hasScreenUpdate = true + + if (ownsDataLease) { + realScope.screen = screen + } + } + + fun setNavigationContext(value: Any?, realScope: IScope, ownsDataLease: Boolean) { + scope.setContexts(NAVIGATION_CONTEXT_KEY, value) + hasNavigationContextUpdate = true + + if (ownsDataLease) { + realScope.setContexts(NAVIGATION_CONTEXT_KEY, value) + } + } + + fun publishTo(realScope: IScope) { + // A new leaseholder publishes a complete navigation state. Clearing fields it hasn't staged + // prevents the previous leaseholder's values from lingering after a handoff. + realScope.screen = if (hasScreenUpdate) screenName else null + realScope.setContexts( + NAVIGATION_CONTEXT_KEY, + if (hasNavigationContextUpdate) contexts[NAVIGATION_CONTEXT_KEY] else null, + ) + } + + fun withPropagationContext(callback: Scope.IWithPropagationContext): PropagationContext = + scope.withPropagationContext(callback) + + /** Publishes the breadcrumb when leased, or buffers it until the first publication. */ + fun addBreadcrumb(ownsDataLease: Boolean, publish: () -> Unit) { + if (ownsDataLease) { + publish() + hasPublishedBreadcrumb = true + pendingInitialBreadcrumb = null + } else if (!hasPublishedBreadcrumb) { + // Replace older unpublished breadcrumbs so only the latest is replayed on first lease. + pendingInitialBreadcrumb = publish + } + } + + /** Publishes the buffered initial breadcrumb if this instance currently owns the data lease. */ + fun publishPendingBreadcrumb(ownsDataLease: Boolean) { + val pending = pendingInitialBreadcrumb ?: return + if (ownsDataLease) { + pending() + hasPublishedBreadcrumb = true + pendingInitialBreadcrumb = null + } + } +} + +/** Coordinates leases for navigation state shared by multiple [SentryNavEffect]s. */ +@Suppress("TooManyFunctions") +internal class NavLeaseCoordinator { + + /** + * The owner currently allowed to publish Sentry nav data (e.g., breadcrumbs, screen names, back + * stack context). + */ + private var dataOwner: Any? = null + + /** The owner currently allowed to start and manage nav transactions. */ + private var transactionOwner: Any? = null + + /** + * The owner of [activeTransaction], which may differ from [transactionOwner] during handoff. The + * previous owner's active transaction remains until the new owner processes a back stack update, + * at which point it is stopped before the new transaction starts. + */ + private var activeTransactionOwner: Any? = null + + private var activeTransaction: ITransaction? = null + + /** + * Checks whether [owner] currently holds the nav data lease. + * + * @return `true` if [owner] is the current data owner, otherwise `false`. + */ + fun ownsData(owner: Any): Boolean = dataOwner === owner + + /** + * Gives [owner] the lease to publish nav data, replacing any previous data owner. + * + * @return `true` if ownership changed to [owner], or `false` if [owner] already held the lease. + */ + fun claimDataOwnership(owner: Any): Boolean { + if (dataOwner === owner) { + return false + } + + dataOwner = owner + return true + } + + /** + * Releases the nav data lease if it is held by [owner]. + * + * @return `true` if the lease was released, or `false` if [owner] did not hold it. + */ + fun releaseDataOwnership(owner: Any): Boolean { + if (!ownsData(owner)) { + return false + } + + dataOwner = null + return true + } + + /** + * Checks whether [owner] currently holds the nav transaction lease. + * + * @return `true` if [owner] is the current transaction owner, otherwise `false`. + */ + fun ownsTransactions(owner: Any): Boolean = transactionOwner === owner + + /** + * Gives [owner] the lease to manage nav transactions, replacing any previous owner. + * + * @return `true` if ownership changed to [owner], or `false` if [owner] already held the lease. + */ + fun claimTransactionOwnership(owner: Any): Boolean { + if (transactionOwner === owner) { + return false + } + + transactionOwner = owner + return true + } + + fun bindTransaction(owner: Any, transaction: ITransaction) { + if (ownsTransactions(owner)) { + activeTransactionOwner = owner + activeTransaction = transaction + } + } + + /** Stops the previous owner's transaction when [owner] next processes a back stack update. */ + fun prepareTransactionUpdate(owner: Any, scope: IScope) { + clearFinishedTransaction() + if (ownsTransactions(owner) && activeTransactionOwner !== owner) { + stopActiveTransaction(scope) + } + } + + /** Stops [owner]'s active transaction and clears it from [scope] if it is still current. */ + fun stopOwnedTransaction(owner: Any, scope: IScope) { + if (activeTransactionOwner === owner) { + stopActiveTransaction(scope) + } + } + + private fun stopActiveTransaction(scope: IScope) { + val transaction = activeTransaction ?: return + + if (!transaction.isFinished) { + transaction.finish(transaction.status ?: SpanStatus.OK) + } + + scope.withTransaction { currentTransaction -> + if (currentTransaction === transaction) { + scope.clearTransaction() + } + } + activeTransactionOwner = null + activeTransaction = null + } + + fun clearFinishedTransaction() { + if (activeTransaction?.isFinished == true) { + activeTransactionOwner = null + activeTransaction = null + } + } + + fun releaseTransactionOwnership(owner: Any) { + if (ownsTransactions(owner)) { + transactionOwner = activeTransactionOwner + } + } +} + +internal val defaultNavLeaseCoordinator: NavLeaseCoordinator = NavLeaseCoordinator() diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/NavSession.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/NavSession.kt new file mode 100644 index 0000000000..a8d3733bad --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/NavSession.kt @@ -0,0 +1,49 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import io.sentry.IScope +import io.sentry.IScopes + +/** + * Coordinates the production and publishing of navigation data for one [SentryNavEffect]. + * + * [BackStackObserver] translates back stack changes into Sentry data, while [INavScopes] controls + * whether a given `SentryNavEffect` may publish that data or whether it should defer to another + * currently active `SentryNavEffect`. + * + * **Usage** + * + * Call [attach] before the first [onBackStackChanged], and call [dispose] when observation ends. + * + * Sessions targeting the same [IScope] must share the same [NavLeaseCoordinator] instance. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. Underlying [INavScopes] lifecycle callbacks and invocations + * of this class's methods should be confined to the same thread. + */ +internal class NavSession( + scopes: IScopes, + options: SentryNavOptions, + coordinator: NavLeaseCoordinator, + entryMapper: ForwardingBackStackEntryMapper, +) { + + private val navScopes = INavScopes(scopes, coordinator) + private val observer = BackStackObserver(navScopes, options, entryMapper) + + fun attach(lifecycle: Lifecycle) { + navScopes.attach(lifecycle) + } + + fun onBackStackChanged(backStack: List) { + navScopes.runBackStackUpdate { observer.onBackStackChanged(backStack) } + } + + fun dispose() { + // Don't call navScopes.dispose() until the observer has been cleaned up, as we need the nav + // scope's lease in order to clear the observer's published navigation state. + observer.cleanup() + navScopes.dispose() + } +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt index d041a03b68..c492ebf278 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.compose.LocalLifecycleOwner import io.sentry.IScopes import io.sentry.ScopesAdapter import io.sentry.SentryOptions @@ -65,9 +66,12 @@ import org.jetbrains.annotations.ApiStatus * gestures. That means that spans produced by predictively rendered composables can show up under * the current destination's transaction. * - * Multiple simultaneously active `SentryNavEffect` instances writing to the same Sentry scope are - * not supported. Violating this restriction can result in interleaved breadcrumbs, clobbered screen - * names and back stacks, and transactions that interfere with one another. + * In general, host apps should ensure that only one `SentryNavEffect` is active at a time. + * `SentryNavEffect` protects against multiple instances emitting data simultaneously during + * [lifecycle owner][androidx.lifecycle.LifecycleOwner] transitions, such as when navigating between + * activities where each Activity has its own `SentryNavEffect`. But `SentryNavEffect`s associated + * with side-by-side or nested `NavDisplay`s are not supported and can result in duplicated or + * interleaved data and undefined transaction behavior. * * @param backStack The navigation backstack to observe. * @param backStackEntryMapper Maps each entry of the [backStack] to a name and optional arguments @@ -87,6 +91,7 @@ public fun SentryNavEffect( backStackEntryMapper = backStackEntryMapper, options = options, scopes = ScopesAdapter.getInstance(), + coordinator = defaultNavLeaseCoordinator, ) } @@ -94,20 +99,29 @@ public fun SentryNavEffect( internal fun SentryNavEffect( backStack: List, backStackEntryMapper: BackStackEntryMapper, - options: SentryNavOptions = SentryNavOptions(), + options: SentryNavOptions, scopes: IScopes, + coordinator: NavLeaseCoordinator, ) { val currentBackStackEntryMapper = rememberUpdatedState(backStackEntryMapper) + val lifecycle = LocalLifecycleOwner.current.lifecycle - val observer = - remember(scopes, options) { - BackStackObserver( + val session = + remember(scopes, options, lifecycle, coordinator) { + NavSession( scopes = scopes, options = options, + coordinator = coordinator, entryMapper = ForwardingBackStackEntryMapper { currentBackStackEntryMapper.value }, ) } + // Attach first so the initial back stack update can start a nav transaction. + DisposableEffect(session, lifecycle) { + session.attach(lifecycle) + onDispose { session.dispose() } + } + // Intentionally don't remember this copy. Snapshot-backed lists mutate in place, so // remember(backStack) { backStack.toList() } would cache a stale copy. (The key reference // retained by remember() and the backStack reference passed to this effect would point to the @@ -116,14 +130,10 @@ internal fun SentryNavEffect( // it with the previous one. val copy = backStack.toList() - DisposableEffect(observer, BackStackKey(copy)) { - observer.onBackStackChanged(backStack = copy) + DisposableEffect(session, BackStackKey(copy)) { + session.onBackStackChanged(backStack = copy) onDispose {} } - - DisposableEffect(observer) { - onDispose { observer.cleanup() } - } } /** diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/INavScopesTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/INavScopesTest.kt new file mode 100644 index 0000000000..f46662fd45 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/INavScopesTest.kt @@ -0,0 +1,843 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.ITransaction +import io.sentry.PropagationContext +import io.sentry.ScopeType +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import org.junit.Test + +/** + * The tests in this suite use the following terminology: + * + * - **Navigation data**: The non-transaction Sentry data emitted by [SentryNavEffect], i.e., screen + * names, back stack context, and breadcrumbs. + * + * - **Navigation state**: Navigation data that has a single, variable-like value, i.e., screen + * names and back stack context. + * + * - **Navigation events**: Navigation data that has accumulated, event-like values, i.e., + * breadcrumbs. + */ +@Suppress("LargeClass") +class INavScopesTest { + + @Test + fun `navigation state is staged before lifecycle enters a resumed state`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(state = Lifecycle.State.CREATED) + fixture.scope.screen = "visible" + + navScopes.attach(owner.lifecycle) + navScopes.configureScope { + it.screen = "pending" + it.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "pending")) + } + + assertThat(fixture.scope.screen).isEqualTo("visible") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + navScopes.configureScope { assertThat(it.screen).isEqualTo("pending") } + + owner.resume() + + assertThat(fixture.scope.screen).isEqualTo("pending") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("route" to "pending")) + } + + @Test + fun `navigation state is staged after lifecycle leaves a resumed state`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + + navScopes.attach(owner.lifecycle) + navScopes.configureScope { it.screen = "Home" } + assertThat(fixture.scope.screen).isEqualTo("Home") + + owner.pause() + + navScopes.configureScope { it.screen = "Profile" } + assertThat(fixture.scope.screen).isEqualTo("Home") + + owner.resume() + + assertThat(fixture.scope.screen).isEqualTo("Profile") + } + + @Test + fun `navigation state is published immediately when lifecycle begins in a resumed state`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + + navScopes.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + navScopes.configureScope { + it.screen = "Home" + it.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Home")) + } + + assertThat(fixture.scope.screen).isEqualTo("Home") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isEqualTo(mapOf("route" to "Home")) + } + + @Test + fun `incoming owner stages nav state updates until lifecycle resumes so long as previous owner is visible`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + firstNavScopes.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + val homeBackStack = mapOf("Back Stack" to listOf(mapOf("entry" to "/Home"))) + firstNavScopes.configureScope { + it.screen = "Home" + it.setContexts(NAVIGATION_CONTEXT_KEY, homeBackStack) + } + val secondNavScopes = fixture.navScopes() + val secondOwner = NavTestLifecycle(Lifecycle.State.CREATED) + secondNavScopes.attach(secondOwner.lifecycle) + val settingsBackStack = mapOf("Back Stack" to listOf(mapOf("entry" to "/Settings"))) + + secondNavScopes.configureScope { + it.screen = "Settings" + it.setContexts(NAVIGATION_CONTEXT_KEY, settingsBackStack) + } + + assertThat(fixture.scope.screen).isEqualTo("Home") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isEqualTo(homeBackStack) + + secondOwner.resume() + + assertThat(fixture.scope.screen).isEqualTo("Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isEqualTo(settingsBackStack) + } + + @Test + fun `cleaning up the previous owner doesn't modify the current navigation state`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + val firstOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + firstNavScopes.attach(firstOwner.lifecycle) + firstNavScopes.configureScope { + it.screen = "Home" + it.setContexts( + NAVIGATION_CONTEXT_KEY, + mapOf("Back Stack" to listOf(mapOf("entry" to "/Home"))), + ) + } + val secondNavScopes = fixture.navScopes() + secondNavScopes.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + val settingsBackStack = mapOf("Back Stack" to listOf(mapOf("entry" to "/Settings"))) + secondNavScopes.configureScope { + it.screen = "Settings" + it.setContexts(NAVIGATION_CONTEXT_KEY, settingsBackStack) + } + + firstNavScopes.configureScope { + it.screen = "unseen" + it.setContexts( + NAVIGATION_CONTEXT_KEY, + mapOf("Back Stack" to listOf(mapOf("entry" to "/Unseen"))), + ) + } + assertThat(fixture.scope.screen).isEqualTo("Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isEqualTo(settingsBackStack) + + firstOwner.pause() + assertThat(fixture.scope.screen).isEqualTo("Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isEqualTo(settingsBackStack) + + firstNavScopes.dispose() + assertThat(fixture.scope.screen).isEqualTo("Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isEqualTo(settingsBackStack) + + val profileBackStack = mapOf("Back Stack" to listOf(mapOf("entry" to "/Profile"))) + secondNavScopes.configureScope { + it.screen = "Profile" + it.setContexts(NAVIGATION_CONTEXT_KEY, profileBackStack) + } + + assertThat(fixture.scope.screen).isEqualTo("Profile") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isEqualTo(profileBackStack) + } + + @Test + fun `a new owner clears any navigation state it didn't stage`() { + val fixture = NavTestFixture() + fixture.scope.screen = "old" + fixture.scope.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "old")) + fixture.scope.setContexts("host", mapOf("value" to "retained")) + + fixture.navScopes().attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + assertThat(fixture.scope.contexts["host"]).isEqualTo(mapOf("value" to "retained")) + } + + @Test + fun `cleared navigation state is published as empty state when lifecycle is resumed`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle() + + navScopes.attach(owner.lifecycle) + navScopes.configureScope { + it.screen = "Home" + it.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Home")) + } + navScopes.configureScope { + it.screen = null + it.setContexts(NAVIGATION_CONTEXT_KEY, null as Any?) + } + + owner.resume() + + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `the latest initial breadcrumb is published when lifecycle is resumed`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle() + + navScopes.attach(owner.lifecycle) + navScopes.addBreadcrumb(Breadcrumb().apply { message = "Home" }) + navScopes.addBreadcrumb(Breadcrumb().apply { message = "Profile" }, Hint()) + assertThat(fixture.scope.breadcrumbs).isEmpty() + + owner.resume() + + assertThat(fixture.scope.breadcrumbs.map { it.message }).containsExactly("Profile") + } + + @Test + fun `breadcrumbs received after publication are discarded if lifecycle is not resumed`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + + navScopes.attach(owner.lifecycle) + navScopes.addBreadcrumb(Breadcrumb().apply { message = "Home" }, Hint()) + assertThat(fixture.scope.breadcrumbs.map { it.message }).containsExactly("Home").inOrder() + + owner.pause() + + navScopes.addBreadcrumb(Breadcrumb().apply { message = "first_unseen" }) + navScopes.addBreadcrumb(Breadcrumb().apply { message = "second_unseen" }) + assertThat(fixture.scope.breadcrumbs.map { it.message }).containsExactly("Home").inOrder() + } + + @Test + fun `breadcrumbs received after publication are published if lifecycle is resumed`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + + navScopes.attach(owner.lifecycle) + navScopes.addBreadcrumb(Breadcrumb().apply { message = "Home" }, Hint()) + assertThat(fixture.scope.breadcrumbs.map { it.message }).containsExactly("Home").inOrder() + + owner.pause() + + navScopes.addBreadcrumb(Breadcrumb().apply { message = "unseen" }) + assertThat(fixture.scope.breadcrumbs.map { it.message }).containsExactly("Home").inOrder() + + owner.resume() + + navScopes.addBreadcrumb(Breadcrumb().apply { message = "Profile" }) + assertThat(fixture.scope.breadcrumbs.map { it.message }) + .containsExactly("Home", "Profile") + .inOrder() + } + + @Test + fun `only the current owner of the data lease publishes breadcrumbs during overlap`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + val firstOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + firstNavScopes.attach(firstOwner.lifecycle) + firstNavScopes.addBreadcrumb(Breadcrumb().apply { message = "Home" }) + val secondNavScopes = fixture.navScopes() + val secondOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + secondNavScopes.attach(secondOwner.lifecycle) + + firstNavScopes.addBreadcrumb(Breadcrumb().apply { message = "unseen" }, Hint()) + secondNavScopes.addBreadcrumb(Breadcrumb().apply { message = "Settings" }, Hint()) + + assertThat(fixture.scope.breadcrumbs.map { it.message }) + .containsExactly("Home", "Settings") + .inOrder() + + firstOwner.pause() + secondOwner.pause() + firstOwner.resume() + + assertThat(fixture.scope.breadcrumbs.map { it.message }) + .containsExactly("Home", "Settings") + .inOrder() + firstNavScopes.addBreadcrumb(Breadcrumb().apply { message = "Profile" }) + assertThat(fixture.scope.breadcrumbs.map { it.message }) + .containsExactly("Home", "Settings", "Profile") + .inOrder() + } + + @Test + fun `each incoming data lease owner publishes only its own latest pending initial breadcrumb`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + val firstOwner = NavTestLifecycle(Lifecycle.State.CREATED) + firstNavScopes.attach(firstOwner.lifecycle) + val secondNavScopes = fixture.navScopes() + val secondOwner = NavTestLifecycle(Lifecycle.State.CREATED) + secondNavScopes.attach(secondOwner.lifecycle) + + firstNavScopes.addBreadcrumb(Breadcrumb().apply { message = "first initial" }) + secondNavScopes.addBreadcrumb(Breadcrumb().apply { message = "second initial" }, Hint()) + firstNavScopes.addBreadcrumb(Breadcrumb().apply { message = "first latest" }, Hint()) + secondNavScopes.addBreadcrumb(Breadcrumb().apply { message = "second latest" }) + assertThat(fixture.scope.breadcrumbs).isEmpty() + + firstOwner.resume() + + assertThat(fixture.scope.breadcrumbs.map { it.message }).containsExactly("first latest") + + firstOwner.pause() + secondOwner.resume() + + assertThat(fixture.scope.breadcrumbs.map { it.message }) + .containsExactly("first latest", "second latest") + .inOrder() + } + + @Test + fun `navigation transactions cannot start without the transaction lease`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + + val transaction = + navScopes.startTransaction(TransactionContext("Home", "navigation"), TransactionOptions()) + + assertThat(transaction.isNoOp).isTrue() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `navigation transactions can start before lifecycle is resumed`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + + val transaction = + navScopes.startTransaction(TransactionContext("Home", "navigation"), TransactionOptions()) + + assertThat(transaction.isNoOp).isFalse() + assertThat(transaction.name).isEqualTo("Home") + assertThat(transaction.operation).isEqualTo("navigation") + assertThat(transaction.isFinished).isFalse() + } + + @Test + fun `navigation transactions can still start while lifecycle is paused`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + navScopes.attach(owner.lifecycle) + + owner.pause() + val transaction = + navScopes.startTransaction(TransactionContext("Profile", "navigation"), TransactionOptions()) + + assertThat(transaction.isNoOp).isFalse() + assertThat(transaction.name).isEqualTo("Profile") + assertThat(transaction.isFinished).isFalse() + } + + @Test + fun `navigation transactions cannot start after another instance takes the transaction lease`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + firstNavScopes.attach(NavTestLifecycle().lifecycle) + val secondNavScopes = fixture.navScopes() + secondNavScopes.attach(NavTestLifecycle().lifecycle) + val currentTransaction = + secondNavScopes.startTransaction( + TransactionContext("Settings", "navigation"), + TransactionOptions(), + ) + secondNavScopes.configureScope { it.transaction = currentTransaction } + + val rejectedTransaction = + firstNavScopes.startTransaction( + TransactionContext("Profile", "navigation"), + TransactionOptions(), + ) + + assertThat(rejectedTransaction.isNoOp).isTrue() + assertThat(currentTransaction.isNoOp).isFalse() + assertThat(currentTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(currentTransaction) + } + + @Test + fun `navigation state updates from the previous owner don't modify the current transaction`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + firstNavScopes.attach(NavTestLifecycle().lifecycle) + val previousTransaction = + firstNavScopes.startTransaction( + TransactionContext("Home", "navigation"), + TransactionOptions(), + ) + firstNavScopes.configureScope { it.transaction = previousTransaction } + val secondNavScopes = fixture.navScopes() + secondNavScopes.attach(NavTestLifecycle().lifecycle) + secondNavScopes.runBackStackUpdate { secondNavScopes.configureScope {} } + val currentTransaction = + secondNavScopes.startTransaction( + TransactionContext("Settings", "navigation"), + TransactionOptions(), + ) + secondNavScopes.configureScope { it.transaction = currentTransaction } + + firstNavScopes.runBackStackUpdate { + firstNavScopes.configureScope { it.screen = "unseen" } + } + + assertThat(currentTransaction.isNoOp).isFalse() + assertThat(currentTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(currentTransaction) + } + + @Test + fun `navigation transaction handoff waits for a back stack update`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + firstNavScopes.attach(NavTestLifecycle().lifecycle) + val transaction = + firstNavScopes.startTransaction( + TransactionContext("Home", "navigation"), + TransactionOptions(), + ) + firstNavScopes.configureScope { it.transaction = transaction } + val secondNavScopes = fixture.navScopes() + + secondNavScopes.attach(NavTestLifecycle().lifecycle) + secondNavScopes.configureScope { it.setTag("host", "value") } + + assertThat(transaction.isNoOp).isFalse() + assertThat(transaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + } + + @Test + fun `navigation transaction handoff finishes and unbinds the previous transaction before applying the update`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + firstNavScopes.attach(NavTestLifecycle().lifecycle) + val previousTransaction = + firstNavScopes.startTransaction( + TransactionContext("Home", "navigation"), + TransactionOptions(), + ) + previousTransaction.status = SpanStatus.CANCELLED + firstNavScopes.configureScope { it.transaction = previousTransaction } + val secondNavScopes = fixture.navScopes() + secondNavScopes.attach(NavTestLifecycle().lifecycle) + var previousWasFinishedDuringUpdate = false + var scopeWasEmptyDuringUpdate = false + var replacementTransaction: ITransaction? = null + + secondNavScopes.runBackStackUpdate { + secondNavScopes.configureScope { + previousWasFinishedDuringUpdate = previousTransaction.isFinished + scopeWasEmptyDuringUpdate = it.transaction == null + replacementTransaction = + secondNavScopes.startTransaction( + TransactionContext("Settings", "navigation"), + TransactionOptions(), + ) + it.transaction = replacementTransaction + } + } + + assertThat(previousWasFinishedDuringUpdate).isTrue() + assertThat(scopeWasEmptyDuringUpdate).isTrue() + assertThat(previousTransaction.status).isEqualTo(SpanStatus.CANCELLED) + val replacement = requireNotNull(replacementTransaction) + assertThat(replacement.isNoOp).isFalse() + assertThat(replacement.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(replacement) + } + + @Test + fun `navigation transaction disposal finishes and unbinds the owned transaction`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle().lifecycle) + val transaction = + navScopes.startTransaction(TransactionContext("Home", "navigation"), TransactionOptions()) + navScopes.configureScope { it.transaction = transaction } + + navScopes.dispose() + + assertThat(transaction.isNoOp).isFalse() + assertThat(transaction.isFinished).isTrue() + assertThat(transaction.status).isEqualTo(SpanStatus.OK) + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `navigation transaction disposal preserves a host transaction that replaced it`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle().lifecycle) + val navigationTransaction = + navScopes.startTransaction(TransactionContext("Home", "navigation"), TransactionOptions()) + navScopes.configureScope { it.transaction = navigationTransaction } + val hostTransaction = fixture.transaction("Host") + fixture.scope.transaction = hostTransaction + + navScopes.dispose() + + assertThat(navigationTransaction.isFinished).isTrue() + assertThat(hostTransaction.isNoOp).isFalse() + assertThat(hostTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `navigation transaction disposal by the former owner preserves the current owners transaction`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + firstNavScopes.attach(NavTestLifecycle().lifecycle) + val previousTransaction = + firstNavScopes.startTransaction( + TransactionContext("Home", "navigation"), + TransactionOptions(), + ) + firstNavScopes.configureScope { it.transaction = previousTransaction } + val secondNavScopes = fixture.navScopes() + secondNavScopes.attach(NavTestLifecycle().lifecycle) + secondNavScopes.runBackStackUpdate { secondNavScopes.configureScope {} } + val currentTransaction = + secondNavScopes.startTransaction( + TransactionContext("Settings", "navigation"), + TransactionOptions(), + ) + secondNavScopes.configureScope { it.transaction = currentTransaction } + + firstNavScopes.dispose() + + assertThat(currentTransaction.isNoOp).isFalse() + assertThat(currentTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(currentTransaction) + } + + @Test + fun `navigation transaction ownership returns to the active owner if the incoming owner disposes before an update`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + firstNavScopes.attach(NavTestLifecycle().lifecycle) + val activeTransaction = + firstNavScopes.startTransaction( + TransactionContext("Home", "navigation"), + TransactionOptions(), + ) + firstNavScopes.configureScope { it.transaction = activeTransaction } + val secondNavScopes = fixture.navScopes() + secondNavScopes.attach(NavTestLifecycle().lifecycle) + + secondNavScopes.dispose() + + assertThat(activeTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(activeTransaction) + val nextTransaction = + firstNavScopes.startTransaction( + TransactionContext("Profile", "navigation"), + TransactionOptions(), + ) + assertThat(nextTransaction.isNoOp).isFalse() + assertThat(nextTransaction.name).isEqualTo("Profile") + } + + @Test + fun `the previous owner's transaction remains active when a navigation update fails`() { + val fixture = NavTestFixture() + + val firstNavScopesInstance = fixture.navScopes() + firstNavScopesInstance.attach(NavTestLifecycle().lifecycle) + val tx = + firstNavScopesInstance.startTransaction( + TransactionContext("Home", "navigation"), + TransactionOptions(), + ) + fixture.scope.transaction = tx + + val secondNavScopesInstance = fixture.navScopes() + secondNavScopesInstance.attach(NavTestLifecycle().lifecycle) + + val exception = + try { + secondNavScopesInstance.runBackStackUpdate { throw IllegalArgumentException("mapper") } + null + } catch (e: IllegalArgumentException) { + e + } + assertThat(exception).isInstanceOf(IllegalArgumentException::class.java) + + secondNavScopesInstance.configureScope { it.setTag("host", "value") } + + assertThat(tx.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(tx) + } + + @Test + fun `navigation transaction lease survives a no-op returned while tracing is disabled`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle().lifecycle) + fixture.options.setTracesSampleRate(null) + + val disabledTransaction = + navScopes.startTransaction(TransactionContext("Home", "navigation"), TransactionOptions()) + assertThat(disabledTransaction.isNoOp).isTrue() + + fixture.options.setTracesSampleRate(1.0) + val enabledTransaction = + navScopes.startTransaction(TransactionContext("Profile", "navigation"), TransactionOptions()) + assertThat(enabledTransaction.isNoOp).isFalse() + assertThat(enabledTransaction.name).isEqualTo("Profile") + } + + @Test + fun `propagation changes are not published without the transaction lease`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle().lifecycle) + fixture.navScopes().attach(NavTestLifecycle().lifecycle) + val shared = fixture.scope.propagationContext.traceId + val privateContext = PropagationContext() + + navScopes.configureScope { scope -> + scope.propagationContext = privateContext + assertThat(scope.propagationContext.traceId).isEqualTo(privateContext.traceId) + scope.withPropagationContext { assertThat(it.traceId).isEqualTo(privateContext.traceId) } + } + + assertThat(fixture.scope.propagationContext.traceId).isEqualTo(shared) + } + + @Test + fun `propagation changes are published if transaction lease is held`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle().lifecycle) + val context = PropagationContext() + + navScopes.configureScope { scope -> + scope.withPropagationContext { scope.propagationContext = context } + } + + assertThat(fixture.scope.propagationContext.traceId).isEqualTo(context.traceId) + } + + @Test + fun `transaction lease is not required for unrelated scope writes`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + fixture.navScopes().attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + + navScopes.configureScope { it.screen = "Home" } + assertThat(fixture.scope.screen).isEqualTo("Home") + val transaction = + navScopes.startTransaction(TransactionContext("Home", "navigation"), TransactionOptions()) + assertThat(transaction.isNoOp).isTrue() + + navScopes.configureScope(ScopeType.ISOLATION) { + it.setTag("host", "value") + it.setContexts("host", mapOf("id" to 42)) + } + + assertThat(fixture.scopes.isolationScope).isNotSameInstanceAs(fixture.scope) + assertThat(fixture.scopes.isolationScope.tags["host"]).isEqualTo("value") + assertThat(fixture.scopes.isolationScope.contexts["host"]).isEqualTo(mapOf("id" to 42)) + } + + @Test + fun `data lease is not required for unrelated scope writes`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + + navScopes.configureScope(ScopeType.ISOLATION) { + it.setTag("host", "value") + it.setContexts("host", mapOf("id" to 42)) + } + + assertThat(fixture.scopes.isolationScope).isNotSameInstanceAs(fixture.scope) + assertThat(fixture.scopes.isolationScope.tags["host"]).isEqualTo("value") + assertThat(fixture.scopes.isolationScope.contexts["host"]).isEqualTo(mapOf("id" to 42)) + } + + @Test + fun `current owner keeps data lease when previous owner lifecycle is paused and navScopes instance is disposed`() { + val fixture = NavTestFixture() + val firstNavScopesInstance = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + firstNavScopesInstance.attach(owner.lifecycle) + + val secondNavScopesInstance = fixture.navScopes() + secondNavScopesInstance.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + + owner.pause() + firstNavScopesInstance.dispose() + + secondNavScopesInstance.configureScope { it.screen = "second" } + firstNavScopesInstance.configureScope { it.screen = "stale" } + assertThat(fixture.scope.screen).isEqualTo("second") + } + + @Test + fun `a disposed navScopes instance ignores later lifecycle events and does not re-acquire leases`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + + navScopes.attach(owner.lifecycle) + navScopes.configureScope { it.screen = "Home" } + assertThat(fixture.scope.screen).isEqualTo("Home") + + owner.pause() + navScopes.dispose() + fixture.scope.screen = "host" + + owner.resume() + + assertThat(fixture.scope.screen).isEqualTo("host") + assertThat( + navScopes + .startTransaction(TransactionContext("Home", "navigation"), TransactionOptions()) + .isNoOp + ) + .isTrue() + } + + @Test + fun `a destroyed lifecycle cannot publish navigation data`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.DESTROYED) + val publishedBackStack = mapOf("Back Stack" to listOf(mapOf("entry" to "/Host"))) + fixture.scope.screen = "Host" + fixture.scope.setContexts(NAVIGATION_CONTEXT_KEY, publishedBackStack) + fixture.scopes.addBreadcrumb(Breadcrumb().apply { message = "Host" }) + + navScopes.configureScope { + it.screen = "Home" + it.setContexts( + NAVIGATION_CONTEXT_KEY, + mapOf("Back Stack" to listOf(mapOf("entry" to "/Home"))), + ) + } + navScopes.addBreadcrumb(Breadcrumb().apply { message = "Home" }) + + navScopes.attach(owner.lifecycle) + + navScopes.configureScope { + it.screen = "Profile" + it.setContexts( + NAVIGATION_CONTEXT_KEY, + mapOf("Back Stack" to listOf(mapOf("entry" to "/Profile"))), + ) + } + navScopes.addBreadcrumb(Breadcrumb().apply { message = "Profile" }, Hint()) + + assertThat(fixture.scope.screen).isEqualTo("Host") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isEqualTo(publishedBackStack) + assertThat(fixture.scope.breadcrumbs.map { it.message }).containsExactly("Host") + } + + @Test + fun `a destroyed lifecycle cannot start navigation transactions`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + + navScopes.attach(NavTestLifecycle(Lifecycle.State.DESTROYED).lifecycle) + + assertThat( + navScopes + .startTransaction(TransactionContext("Home", "navigation"), TransactionOptions()) + .isNoOp + ) + .isTrue() + } + + @Test + fun `attaching the same lifecycle twice does not republish navigation data`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + + navScopes.configureScope { + it.screen = "Home" + it.setContexts( + NAVIGATION_CONTEXT_KEY, + mapOf("Back Stack" to listOf(mapOf("entry" to "/Home"))), + ) + } + navScopes.addBreadcrumb(Breadcrumb().apply { message = "Home" }) + navScopes.attach(owner.lifecycle) + assertThat(fixture.scope.breadcrumbs.map { it.message }).containsExactly("Home") + + val hostBackStack = mapOf("Back Stack" to listOf(mapOf("entry" to "/Host"))) + fixture.scope.screen = "host override" + fixture.scope.setContexts(NAVIGATION_CONTEXT_KEY, hostBackStack) + + navScopes.attach(owner.lifecycle) + + assertThat(fixture.scope.screen).isEqualTo("host override") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isEqualTo(hostBackStack) + assertThat(fixture.scope.breadcrumbs.map { it.message }).containsExactly("Home") + } + + @Test + fun `attaching the same lifecycle twice does not start a new navigation transaction`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + + navScopes.attach(owner.lifecycle) + val transaction = + navScopes.startTransaction(TransactionContext("Home", "navigation"), TransactionOptions()) + assertThat(transaction.isNoOp).isFalse() + navScopes.configureScope { it.transaction = transaction } + + navScopes.attach(owner.lifecycle) + + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + assertThat(transaction.isFinished).isFalse() + } + + @Test + fun `trying to attach multiple lifecycles throws`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + + navScopes.attach(NavTestLifecycle().lifecycle) + + val exception = + try { + navScopes.attach(NavTestLifecycle().lifecycle) + null + } catch (e: IllegalStateException) { + e + } + assertThat(exception).isInstanceOf(IllegalStateException::class.java) + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavLeaseCoordinatorTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavLeaseCoordinatorTest.kt new file mode 100644 index 0000000000..264ae95232 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavLeaseCoordinatorTest.kt @@ -0,0 +1,163 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import io.sentry.SpanStatus +import org.junit.Test + +class NavLeaseCoordinatorTest { + + @Test + fun `data ownership changes once per claim and can only be released by its owner`() { + val coordinator = NavLeaseCoordinator() + val firstOwner = Any() + val secondOwner = Any() + + assertThat(coordinator.ownsData(firstOwner)).isFalse() + + assertThat(coordinator.claimDataOwnership(firstOwner)).isTrue() + assertThat(coordinator.claimDataOwnership(firstOwner)).isFalse() + assertThat(coordinator.claimDataOwnership(secondOwner)).isTrue() + + assertThat(coordinator.releaseDataOwnership(firstOwner)).isFalse() + assertThat(coordinator.ownsData(secondOwner)).isTrue() + + assertThat(coordinator.releaseDataOwnership(secondOwner)).isTrue() + assertThat(coordinator.ownsData(secondOwner)).isFalse() + } + + @Test + fun `owners are distinguished by identity even when structurally equal`() { + val coordinator = NavLeaseCoordinator() + val firstOwner = listOf("same") + val secondOwner = listOf("same") + + coordinator.claimDataOwnership(firstOwner) + coordinator.claimTransactionOwnership(firstOwner) + + assertThat(coordinator.ownsData(secondOwner)).isFalse() + assertThat(coordinator.ownsTransactions(secondOwner)).isFalse() + } + + @Test + fun `transaction handoff finishes the previous transaction with its existing status`() { + val fixture = NavTestFixture() + val firstOwner = Any() + val secondOwner = Any() + val tx = fixture.transaction() + tx.status = SpanStatus.CANCELLED + fixture.scope.transaction = tx + fixture.coordinator.claimTransactionOwnership(firstOwner) + fixture.coordinator.bindTransaction(firstOwner, tx) + fixture.coordinator.claimTransactionOwnership(secondOwner) + assertThat(tx.isFinished).isFalse() + + fixture.coordinator.prepareTransactionUpdate(secondOwner, fixture.scope) + + assertThat(tx.isFinished).isTrue() + assertThat(tx.status).isEqualTo(SpanStatus.CANCELLED) + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `transaction handoff defaults an unset status to OK`() { + val fixture = NavTestFixture() + val firstOwner = Any() + val secondOwner = Any() + val tx = fixture.transaction() + + fixture.coordinator.claimTransactionOwnership(firstOwner) + fixture.coordinator.bindTransaction(firstOwner, tx) + fixture.coordinator.claimTransactionOwnership(secondOwner) + fixture.coordinator.prepareTransactionUpdate(secondOwner, fixture.scope) + + assertThat(tx.isFinished).isTrue() + assertThat(tx.status).isEqualTo(SpanStatus.OK) + } + + @Test + fun `handoff preserves a host transaction that replaced the navigation transaction`() { + val fixture = NavTestFixture() + val firstOwner = Any() + val secondOwner = Any() + val navTx = fixture.transaction("navigation") + val hostTx = fixture.transaction("host") + + fixture.coordinator.claimTransactionOwnership(firstOwner) + fixture.coordinator.bindTransaction(firstOwner, navTx) + fixture.scope.transaction = hostTx + fixture.coordinator.claimTransactionOwnership(secondOwner) + fixture.coordinator.prepareTransactionUpdate(secondOwner, fixture.scope) + + assertThat(navTx.isFinished).isTrue() + assertThat(hostTx.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTx) + } + + @Test + fun `non-owner cannot bind or stop another owners transaction`() { + val fixture = NavTestFixture() + val firstOwner = Any() + val secondOwner = Any() + val tx = fixture.transaction() + + fixture.coordinator.claimTransactionOwnership(firstOwner) + fixture.coordinator.bindTransaction(firstOwner, tx) + fixture.coordinator.bindTransaction(secondOwner, fixture.transaction("unowned")) + fixture.coordinator.prepareTransactionUpdate(secondOwner, fixture.scope) + fixture.coordinator.stopOwnedTransaction(secondOwner, fixture.scope) + assertThat(tx.isFinished).isFalse() + + fixture.coordinator.stopOwnedTransaction(firstOwner, fixture.scope) + + assertThat(tx.isFinished).isTrue() + } + + @Test + fun `releasing an incoming owner restores ownership to the still active transaction owner`() { + val fixture = NavTestFixture() + val firstOwner = Any() + val secondOwner = Any() + + fixture.coordinator.claimTransactionOwnership(firstOwner) + fixture.coordinator.bindTransaction(firstOwner, fixture.transaction()) + fixture.coordinator.claimTransactionOwnership(secondOwner) + fixture.coordinator.releaseTransactionOwnership(secondOwner) + + assertThat(fixture.coordinator.ownsTransactions(firstOwner)).isTrue() + assertThat(fixture.coordinator.ownsTransactions(secondOwner)).isFalse() + } + + @Test + fun `finished transaction owners are not restored after releasing an incoming owner`() { + val fixture = NavTestFixture() + val firstOwner = Any() + val secondOwner = Any() + val tx = fixture.transaction() + + fixture.coordinator.claimTransactionOwnership(firstOwner) + fixture.coordinator.bindTransaction(firstOwner, tx) + + tx.finish() + + fixture.coordinator.clearFinishedTransaction() + fixture.coordinator.claimTransactionOwnership(secondOwner) + fixture.coordinator.releaseTransactionOwnership(secondOwner) + + assertThat(fixture.coordinator.ownsTransactions(firstOwner)).isFalse() + assertThat(fixture.coordinator.ownsTransactions(secondOwner)).isFalse() + } + + @Test + fun `releasing a former owner leaves the current transaction lease intact`() { + val coordinator = NavLeaseCoordinator() + val firstOwner = Any() + val secondOwner = Any() + assertThat(coordinator.claimTransactionOwnership(firstOwner)).isTrue() + assertThat(coordinator.claimTransactionOwnership(firstOwner)).isFalse() + + coordinator.claimTransactionOwnership(secondOwner) + coordinator.releaseTransactionOwnership(firstOwner) + + assertThat(coordinator.ownsTransactions(secondOwner)).isTrue() + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavSessionTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavSessionTest.kt new file mode 100644 index 0000000000..6e5e3e563c --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavSessionTest.kt @@ -0,0 +1,281 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class NavSessionTest { + + @Test + fun `the initial destination can start a navigation transaction before the owner enters a resumed state`() { + val fixture = NavTestFixture() + val session = fixture.session() + val owner = NavTestLifecycle(Lifecycle.State.CREATED) + session.attach(owner.lifecycle) + + session.onBackStackChanged(listOf("Home")) + + val transaction = requireNotNull(fixture.scope.transaction) + assertThat(transaction.name).isEqualTo("/Home") + assertThat(transaction.isFinished).isFalse() + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + assertThat(fixture.scope.breadcrumbs).isEmpty() + } + + @Test + fun `resuming the owner publishes the latest pending destination without replaying earlier breadcrumbs`() { + val fixture = NavTestFixture() + val session = fixture.session() + val owner = NavTestLifecycle(Lifecycle.State.CREATED) + session.attach(owner.lifecycle) + session.onBackStackChanged(listOf("Home")) + session.onBackStackChanged(listOf("Home", "Profile")) + assertThat(fixture.scope.breadcrumbs).isEmpty() + + owner.resume() + + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo( + mapOf("Back Stack" to listOf(mapOf("entry" to "/Profile"), mapOf("entry" to "/Home"))) + ) + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Profile") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + } + + @Test + fun `changing the visible destination publishes navigation data and replaces the navigation transaction`() { + val fixture = NavTestFixture() + val session = fixture.session() + session.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + session.onBackStackChanged(listOf("Home")) + val previousTransaction = requireNotNull(fixture.scope.transaction) + + session.onBackStackChanged(listOf("Home", "Profile")) + + assertThat(previousTransaction.isFinished).isTrue() + val currentTransaction = requireNotNull(fixture.scope.transaction) + assertThat(currentTransaction).isNotSameInstanceAs(previousTransaction) + assertThat(currentTransaction.name).isEqualTo("/Profile") + assertThat(currentTransaction.isFinished).isFalse() + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo( + mapOf("Back Stack" to listOf(mapOf("entry" to "/Profile"), mapOf("entry" to "/Home"))) + ) + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Profile") + .inOrder() + assertThat(fixture.scope.breadcrumbs.last().data["from"]).isEqualTo("/Home") + } + + @Test + fun `incoming session replaces previous txn before resume without overwriting published navigation data`() { + val fixture = NavTestFixture() + val previousSession = fixture.session() + previousSession.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + previousSession.onBackStackChanged(listOf("Home")) + val previousTransaction = requireNotNull(fixture.scope.transaction) + val incomingSession = fixture.session() + incomingSession.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + assertThat(previousTransaction.isFinished).isFalse() + + incomingSession.onBackStackChanged(listOf("Settings")) + + assertThat(previousTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction?.name).isEqualTo("/Settings") + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Home")))) + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + } + + @Test + fun `previous session destination changes do not overwrite the current sessions navigation data and transaction`() { + val fixture = NavTestFixture() + val previousSession = fixture.session() + previousSession.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + previousSession.onBackStackChanged(listOf("Home")) + val currentSession = fixture.session() + currentSession.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + currentSession.onBackStackChanged(listOf("Settings")) + val currentTransaction = requireNotNull(fixture.scope.transaction) + + previousSession.onBackStackChanged(listOf("Profile")) + + assertThat(fixture.scope.screen).isEqualTo("/Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Settings")))) + assertThat(fixture.scope.transaction).isSameInstanceAs(currentTransaction) + assertThat(currentTransaction.isFinished).isFalse() + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Settings") + .inOrder() + } + + @Test + fun `disposing the previous session does not modify the current sessions navigation data and transaction`() { + val fixture = NavTestFixture() + val previousSession = fixture.session() + previousSession.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + previousSession.onBackStackChanged(listOf("Home")) + val currentSession = fixture.session() + currentSession.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + currentSession.onBackStackChanged(listOf("Settings")) + val currentTransaction = requireNotNull(fixture.scope.transaction) + + previousSession.dispose() + + assertThat(fixture.scope.screen).isEqualTo("/Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Settings")))) + assertThat(fixture.scope.transaction).isSameInstanceAs(currentTransaction) + assertThat(currentTransaction.isFinished).isFalse() + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Settings") + .inOrder() + } + + @Test + fun `disposing an incoming session before its first update lets the previous session continue navigation`() { + val fixture = NavTestFixture() + val previousSession = fixture.session() + previousSession.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + previousSession.onBackStackChanged(listOf("Home")) + val previousTransaction = requireNotNull(fixture.scope.transaction) + val incomingSession = fixture.session() + incomingSession.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + + incomingSession.dispose() + + assertThat(previousTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(previousTransaction) + + previousSession.onBackStackChanged(listOf("Profile")) + + assertThat(previousTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Profile")))) + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Profile") + .inOrder() + } + + @Test + fun `an incoming session with an empty back stack finishes the previous transaction without starting another`() { + val fixture = NavTestFixture() + val previousSession = fixture.session() + previousSession.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + previousSession.onBackStackChanged(listOf("Home")) + val previousTransaction = requireNotNull(fixture.scope.transaction) + val incomingSession = fixture.session() + incomingSession.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + + incomingSession.onBackStackChanged(emptyList()) + + assertThat(previousTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `incoming session with transactions disabled finishes the previous txn and publishes navigation data`() { + val fixture = NavTestFixture() + val previousSession = fixture.session() + previousSession.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + previousSession.onBackStackChanged(listOf("Home")) + val previousTransaction = requireNotNull(fixture.scope.transaction) + val incomingSession = fixture.session(SentryNavOptions { enableNavigationTransactions = false }) + incomingSession.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + + incomingSession.onBackStackChanged(listOf("Settings")) + + assertThat(previousTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isEqualTo("/Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Settings")))) + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Settings") + } + + @Test + fun `session updates and disposal preserve an active host transaction`() { + val fixture = NavTestFixture() + val hostTransaction = fixture.transaction() + fixture.scope.transaction = hostTransaction + val session = fixture.session() + session.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + + session.onBackStackChanged(listOf("Home")) + + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + + session.dispose() + + assertThat(hostTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `disposing current session clears nav state and finishes its txn while retaining breadcrumbs`() { + val fixture = NavTestFixture() + val session = fixture.session() + session.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + session.onBackStackChanged(listOf("Home")) + val transaction = requireNotNull(fixture.scope.transaction) + + session.dispose() + + assertThat(transaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + } + + @Test + fun `disposing an already disposed session preserves navigation state published afterwards`() { + val fixture = NavTestFixture() + val session = fixture.session() + session.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + session.onBackStackChanged(listOf("Home")) + session.dispose() + val hostBackStack = mapOf("Back Stack" to listOf(mapOf("entry" to "/Host"))) + fixture.scope.screen = "Host" + fixture.scope.setContexts(NAVIGATION_CONTEXT_KEY, hostBackStack) + val hostTransaction = fixture.transaction() + fixture.scope.transaction = hostTransaction + + session.dispose() + + assertThat(fixture.scope.screen).isEqualTo("Host") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isEqualTo(hostBackStack) + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + assertThat(hostTransaction.isFinished).isFalse() + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + } + + @Test + fun `destroying the owner finishes the navigation transaction while retaining navigation data for crash reporting`() { + val fixture = NavTestFixture() + val session = fixture.session() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + session.attach(owner.lifecycle) + session.onBackStackChanged(listOf("Home")) + val transaction = requireNotNull(fixture.scope.transaction) + + owner.destroy() + + assertThat(transaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Home")))) + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavTestFixture.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavTestFixture.kt new file mode 100644 index 0000000000..4902a0e408 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavTestFixture.kt @@ -0,0 +1,74 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import io.sentry.ISentryClient +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeType +import io.sentry.Scopes +import io.sentry.SentryOptions +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** Real SDK state with only the event delivery boundary stubbed. No timers or global SDK state. */ +internal class NavTestFixture { + + val options = + SentryOptions().apply { + dsn = "http://key@localhost/1" + defaultScopeType = ScopeType.CURRENT + setTracesSampleRate(1.0) + idleTimeout = null + deadlineTimeout = 0 + } + + val scope = Scope(options) + val scopes = Scopes(scope, Scope(options), Scope(options), "navigation test") + + val coordinator = NavLeaseCoordinator() + + init { + val client = mock() + whenever(client.isEnabled).thenReturn(true) + scopes.bindClient(client) + } + + fun navScopes(): INavScopes = INavScopes(scopes, coordinator) + + fun session(navOptions: SentryNavOptions = SentryNavOptions()): NavSession = + NavSession( + scopes = scopes, + options = navOptions, + coordinator = coordinator, + entryMapper = + ForwardingBackStackEntryMapper { BackStackEntryMapper { SentryBackStackEntry(it) } }, + ) + + fun transaction(name: String = "host"): ITransaction = + scopes.startTransaction(TransactionContext(name, "navigation"), TransactionOptions()) +} + +internal class NavTestLifecycle(state: Lifecycle.State = Lifecycle.State.CREATED) : LifecycleOwner { + + override val lifecycle = + LifecycleRegistry.createUnsafe(this).apply { + if (state == Lifecycle.State.DESTROYED) currentState = Lifecycle.State.CREATED + currentState = state + } + + fun resume() { + lifecycle.currentState = Lifecycle.State.RESUMED + } + + fun pause() { + lifecycle.currentState = Lifecycle.State.STARTED + } + + fun destroy() { + lifecycle.currentState = Lifecycle.State.DESTROYED + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt index 93c1cde567..7165b8bbca 100644 --- a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt @@ -3,11 +3,14 @@ package io.sentry.compose.navigation3 import android.app.Application import android.content.ComponentName import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat @@ -73,6 +76,7 @@ class SentryNavEffectTest { } val scope = Scope(options) val scopes = mock() + val coordinator = NavLeaseCoordinator() val breadcrumbs = mutableListOf() val transactions = mutableListOf() @@ -112,6 +116,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } @@ -135,6 +140,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -162,6 +168,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -189,6 +196,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -222,6 +230,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -255,6 +264,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions { maxCapturedBackStackEntries = 0 }, scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -278,6 +288,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -314,6 +325,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) val currentTop = backStack.last() @@ -347,6 +359,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -384,6 +397,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -416,6 +430,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -458,6 +473,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -495,6 +511,7 @@ class SentryNavEffectTest { options = options.value, backStackEntryMapper = defaultEntryMapper, scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -524,7 +541,9 @@ class SentryNavEffectTest { SentryNavEffect( backStack = backStack, backStackEntryMapper = defaultEntryMapper, + options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } } @@ -542,6 +561,69 @@ class SentryNavEffectTest { assertThat(fixture.scope.contexts.containsKey(NAVIGATION_CONTEXT_KEY)).isFalse() } + @Test + fun `effect publishes staged navigation when its lifecycle owner resumes`() { + val fixture = NavTestFixture() + val owner = NavTestLifecycle() + val options = SentryNavOptions() + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner) { + SentryNavEffect( + backStack = listOf("Home"), + backStackEntryMapper = BackStackEntryMapper { SentryBackStackEntry(it) }, + options = options, + scopes = fixture.scopes, + coordinator = fixture.coordinator, + ) + } + } + composeRule.runOnIdle { + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.screen).isNull() + owner.resume() + } + composeRule.runOnIdle { + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + } + } + + @Test + fun `replacing lifecycle owner disposes the old session and observes the new owner`() { + val fixture = NavTestFixture() + val originalOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + val replacementOwner = NavTestLifecycle() + val owner = mutableStateOf(originalOwner) + val options = SentryNavOptions() + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner.value) { + SentryNavEffect( + backStack = listOf("Home"), + backStackEntryMapper = BackStackEntryMapper { SentryBackStackEntry(it) }, + options = options, + scopes = fixture.scopes, + coordinator = fixture.coordinator, + ) + } + } + lateinit var originalTransaction: ITransaction + composeRule.runOnIdle { + originalTransaction = requireNotNull(fixture.scope.transaction) + owner.value = replacementOwner + } + composeRule.runOnIdle { + assertThat(originalTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNotSameInstanceAs(originalTransaction) + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.screen).isNull() + originalOwner.pause() + originalOwner.resume() + assertThat(fixture.scope.screen).isNull() + replacementOwner.resume() + assertThat(fixture.scope.screen).isEqualTo("/Home") + } + } + private fun IScope.navigationBackStack(): List>? { val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 4e80ddfd86..c30820f24c 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -123,6 +123,18 @@ 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 5b935a01e0..5ec23d98e4 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 @@ -464,6 +464,17 @@ 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) 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 0000000000..9445e7f407 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt @@ -0,0 +1,281 @@ +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.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +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 + +/** Keeps its navigation effect alive while checkout runs in another Activity. */ +class Nav3ProductsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + setContent { + Nav3SampleTheme { + MultiActivityNavDisplay( + activity = this, + initialRoute = Nav3Route.ProductList, + configuration = configuration, + onFinish = { finish() }, + onCheckout = { productId -> + startActivity( + 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. */ +class Nav3CheckoutActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration() + val productId = intent.getStringExtra(EXTRA_PRODUCT_ID) ?: "42" + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + setContent { + Nav3SampleTheme { + MultiActivityNavDisplay( + activity = this, + initialRoute = Nav3Route.Checkout(productId), + configuration = configuration, + onFinish = { finish() }, + ) + } + } + } +} + +@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"), + ) + }, + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun MultiActivityNavDisplay( + activity: ComponentActivity, + initialRoute: Nav3Route, + configuration: NavigationSampleConfig, + onFinish: () -> Unit, + onCheckout: (String) -> Unit = {}, +) { + val backStack = rememberSaveableNav3BackStack(initialRoute) + var routeWorkOptions by remember { mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN)) } + var showTransactionHistorySheet by remember { mutableStateOf(false) } + var showCrashConfirmation by remember { mutableStateOf(false) } + val transactionHistory = + remember(activity) { + NavigationTransactionHistory { + activity.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } + } + DisposableEffect(transactionHistory) { + transactionHistory.install() + onDispose { transactionHistory.uninstall() } + } + 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( + modifier = Modifier.fillMaxSize().safeDrawingPadding(), + topBar = { + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + if (initialRoute is Nav3Route.Checkout) "Checkout Activity" else "Products Activity", + 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 Multi-Activity") }, + onCrashApp = { showCrashConfirmation = true }, + ) + }, + ) { padding -> + NavDisplay( + backStack = backStack, + modifier = Modifier.fillMaxSize().padding(padding), + onBack = onBack, + entryProvider = + entryProvider { + entry { route -> + TracedNav3Route(route, Nav3Scenario.MULTI_ACTIVITY) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductListRoute(backStack) + } + } + entry { route -> + TracedNav3Route(route, Nav3Scenario.MULTI_ACTIVITY) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductDetailRoute( + route = route, + backStack = backStack, + onCheckout = { onCheckout(route.productId) }, + showOverlays = false, + ) + } + } + entry { route -> + TracedNav3Route(route, Nav3Scenario.MULTI_ACTIVITY) { + Nav3RouteWorkEffect(route, routeWorkOptions) + CheckoutRoute(route, backStack) + } + } + entry { route -> + TracedNav3Route(route, Nav3Scenario.MULTI_ACTIVITY) { + 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 Multi-Activity") + } + ) { + Text("Crash") + } + }, + ) + } + if (showTransactionHistorySheet) { + NavigationTransactionHistorySheet( + sampleName = "Nav3", + transactions = transactionHistory.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 EXTRA_PRODUCT_ID = "io.sentry.samples.android.navigation.nav3.product_id" 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 1d4b933fdc..4fed3dbd53 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 @@ -496,6 +496,8 @@ internal fun ProductListRoute(backStack: SnapshotStateList) { internal fun ProductDetailRoute( route: Nav3Route.ProductDetail, backStack: SnapshotStateList, + onCheckout: () -> Unit = { backStack.add(Nav3Route.Checkout(route.productId)) }, + showOverlays: Boolean = true, ) { LaunchedEffect(route.productId, route.source, route.campaign) { recordSimulatedBackgroundSpan(RouteNames.PRODUCT_DETAIL) @@ -508,19 +510,21 @@ 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"), ) }, 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 b9972a6cd7..22d2bcf871 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,14 @@ 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 INavScopes to coordinate state updates between " + + "SentryNavEffect instances. Each Activity owns its own SentryNavEffect and NavDisplay.", + ) Nav3Route.DeepLink -> RouteSpec( routeName = Nav3Route.DeepLink.routeName, @@ -69,6 +77,7 @@ 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.DeepLink -> putString("type", "deep_link") Nav3Route.ProductList -> putString("type", "product_list") is Nav3Route.ProductDetail -> { @@ -108,6 +117,7 @@ private fun Bundle.toNav3Route(): Nav3Route { "landing" -> Nav3Route.Landing "single_stack" -> Nav3Route.SingleStack "custom" -> Nav3Route.Custom + "multi_activity" -> Nav3Route.MultiActivity "deep_link" -> Nav3Route.DeepLink "product_list" -> Nav3Route.ProductList "product_detail" -> { @@ -148,6 +158,7 @@ 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.DEEP_LINK -> resetTo(Nav3Route.DeepLink) Nav3Scenario.MULTIPANE -> resetTo(Nav3Route.Multipane) Nav3Scenario.MULTIPLE_STACKS -> resetTo(Nav3Route.Multistack) @@ -211,6 +222,10 @@ internal sealed interface Nav3Route { override val routeName: String = "Custom" } + data object MultiActivity : Nav3Route { + override val routeName: String = "MultiActivity" + } + data object DeepLink : Nav3Route { override val routeName: String = RouteNames.DEEP_LINK } @@ -282,6 +297,7 @@ 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"), MULTIPANE("Multipane"), MULTIPLE_STACKS("Multistack"), PERFORMANCE("Performance"), @@ -293,6 +309,7 @@ internal val Nav3Scenario.initialRoute: Nav3Route Nav3Scenario.LANDING -> Nav3Route.Landing Nav3Scenario.SINGLE_STACK -> Nav3Route.SingleStack Nav3Scenario.CUSTOM -> Nav3Route.Custom + Nav3Scenario.MULTI_ACTIVITY -> Nav3Route.MultiActivity Nav3Scenario.DEEP_LINK -> Nav3Route.DeepLink Nav3Scenario.MULTIPANE -> Nav3Route.Multipane Nav3Scenario.MULTIPLE_STACKS -> Nav3Route.Multistack