diff --git a/CHANGELOG.md b/CHANGELOG.md index cbf192eeb3..eeab03f1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Keep Navigation 3 transaction and navigation data ownership aligned across lifecycle handoffs ([#6229](https://github.com/getsentry/sentry-java/pull/6229)) + ## 8.60.0 ### Features diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5d79bda786..63adf6a109 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ gummyBears = "0.12.0" java8Signature = "1.0" jackson = "2.18.3" jetbrainsCompose = "1.6.11" +jetbrainsLifecycle = "2.8.4" kotlin = "2.3.21" kotlin-compatible-version = "1.9" ksp = "2.3.9" @@ -139,6 +140,7 @@ jackson-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jac jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0.0" } +jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrainsLifecycle" } kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index da7ad9f36f..83d9a5d462 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.jetbrains.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 37f694350f..93a1178091 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..7f9cb22aae --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/INavScopes.kt @@ -0,0 +1,719 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP +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: + * ``` + * SentryNavEffect + * ↓ owns + * NavSession + * ├── BackStackObserver — computes nav data and nav transactions + * └── INavScopes — controls whether those results reach the real scope + * ↓ + * NavLeaseCoordinator — arbitrates ownership across all effects + * ↓ + * shared IScope + * ``` + * + * **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); + * + * - normally 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). + * + * This class does ***not*** support scenarios where multiple `SentryNavEffect`s are meant to be + * simultaneously active, whether nested or as siblings (e.g., + * [multi-resume mode](https://developer.android.com/develop/ui/views/layout/support-multi-window-mode) + * across two app activities). + * + * **A fundamental tradeoff** + * + * During `LifecycleOwner` overlap, the current transaction and scope can reflect **_either_**: + * + * 1. the outgoing owner that's still visible (in `STARTED` state after pausing), or + * 2. the incoming owner that may crash before it becomes visible (in `CREATED` state) + * + * but not both. We've opted for (2) so that incoming destination populates crash context and work + * performed is associated with it†, as start-up logic is more likely to be the source of a crash or + * interesting spans than the outgoing destination's steady state. (If the incoming owner aborts + * prematurely, the nav transaction and data for the most recently resumed owner still in at least + * `STARTED` state is restored.) + * + * † Associating work with the incoming transaction means spans produced by the outgoing owner may + * appear as if they were produced by the incoming owner (or vice versa, were our policy reversed). + * A possible solution would be to implement a parent span delivery system similar to + * [LocalSentrySpan], but that's a task for another day. + * + * **Only leaseholders generate nav transactions and nav data** + * + * This class works by only allowing `INavScopes` instances holding a lease to mutate the underlying + * `IScope`. + * + * `INavScopes` instances without a lease write to a [stagedNavData] holder, which lets them + * preserve their latest updates without overwriting another instance's published data. When an + * instance acquires the lease, it publishes its staged data to the underlying `IScope`. + * + * **Lease handoff policy** + * + * The lease is acquired preemptively by an incoming `LifecycleOwner` when it applies its first + * [back stack update][runBackStackUpdate]. + * + * The displaced owner (if any) remains eligible for restoration until it stops. Pausing releases + * the lease but preserves eligibility. If the incoming owner aborts before the displaced owner + * stops, the lease reverts to the displaced owner and its nav data and unfinished transaction are + * restored. + * + * The following diagram illustrates how handoff works: + * ``` + * Time + * │ + * ▼ + * + * SentryNavEffect A Shared state SentryNavEffect B + * ───────────────── ──────────── ───────────────── + * + * attach() Navigation lease: none + * │ + * Initial back stack update + * │ + * ├── acquire lease ────────► Navigation lease: A + * ├── create & bind txA ────► Scope transaction: txA + * ├── publish nav data ─────► Published data: A + * │ + * ON_RESUME A is eligible for restoration + * │ + * ON_PAUSE + * │ + * ├────────────────────────► Navigation lease: none + * │ txA and published data remain + * │ A remains eligible for restoration + * │ + * │ attach() + * │ │ + * │ Navigation lease: none ◄─────────────────┤ + * │ Scope transaction: txA │ + * │ Published data: A │ + * │ B registered, awaiting initial back │ + * │ stack update │ + * │ │ + * │ Initial back stack update + * │ │ + * │ suspend & unbind txA ─────┤ + * │ Navigation lease: B ◄── acquire lease ───┤ + * │ Scope transaction: txB ◄── bind txB ─────┤ + * │ Published data: B ◄── publish nav data ──┤ + * │ (rollback point) │ + * │ │ + * │ ON_RESUME + * │ │ + * │ B eligible for restoration ◄─────────────┤ + * │ │ + * ON_STOP │ + * │ │ + * ├── retain txA; timers continue │ + * ├────────────────────────► A no longer eligible for restoration │ + * │ │ + * ON_DESTROY │ + * │ │ + * ├── finish retained txA if unfinished │ + * └────────────────────────► A no longer tracked │ + * ON_PAUSE + * │ + * Navigation lease: none ◄─────────────────┤ + * Published data: B remains │ + * Existing txB may finish naturally │ + * No new transactions or publication by B │ + * │ + * ON_STOP + * │ + * B no longer eligible for restoration ◄───┤ + * │ + * ON_DESTROY + * │ + * Finish & clear txB if still bound ◄──────┤ + * Clear B's remaining screen/back stack │ + * Retain recorded breadcrumbs │ + * ▼ + * + * Alternative at rollback point: B disposes before A stops + * Finish & unbind txB ◄────── dispose B ───┤ + * Navigation lease: A │ + * Restore txA if unfinished and scope free │ + * Republish A's staged data │ + * ``` + * + * **Encapsulation policy** + * + * This class and [NavLeaseCoordinator] are meant to fully encapsulate knowledge about the + * possibility of multiple `SentryNavEffect`s and the shared mutable state issues it raises. + * Addressing those concerns is complicated – don't let that complexity escape into the rest of the + * `SentryNavEffect` system! + * + * **Usage** + * + * 1. Create an `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 stagedNavData = StagedNavData() + + /** + * Whether [runBackStackUpdate] is processing a back-stack update. + * + * This flag distinguishes a [back stack update][runBackStackUpdate] from other requests that also + * invoke [configureScope]. Needed in order to transfer lease ownership for the former but not the + * latter. + */ + private var isProcessingBackStackUpdate: Boolean = false + + /** + * Registers this [INavScopes] instance with [lifecycle]. 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.registerCandidate( + owner = owner, + isResumed = lifecycle.currentState == Lifecycle.State.RESUMED, + isStarted = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED), + clearPublishedData = stagedNavData::clearPublishedFrom, + publish = ::publishStagedNavData, + ) + + lifecycle.addObserver(this) + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + ON_START -> coordinator.startCandidate(owner) + ON_RESUME -> delegate.configureScope { coordinator.resumeCandidate(owner, it)?.invoke() } + ON_PAUSE -> delegate.configureScope { coordinator.pauseCandidate(owner, it)?.invoke() } + ON_STOP -> delegate.configureScope { coordinator.stopCandidate(owner, it)?.invoke() } + ON_DESTROY -> dispose() + else -> Unit + } + } + + /** + * Runs [block] as a back stack update eligible to acquire the navigation lease. + * + * The observer prepares its update before invoking [configureScope], which acquires the lease + * immediately before application. Preparation failures leave the previous owner intact. + */ + fun runBackStackUpdate(block: () -> Unit) { + isProcessingBackStackUpdate = true + try { + block() + } finally { + isProcessingBackStackUpdate = false + } + } + + override fun configureScope(scopeType: ScopeType?, callback: ScopeCallback) { + val leasedCallback = ScopeCallback { scope -> + if (isProcessingBackStackUpdate) { + coordinator.activateCandidate(owner, scope)?.invoke() + } + callback.run(LeasedNavigationScope(scope)) + } + + if (scopeType == null) { + delegate.configureScope(leasedCallback) + } else { + delegate.configureScope(scopeType, leasedCallback) + } + } + + override fun startTransaction( + transactionContext: TransactionContext, + transactionOptions: TransactionOptions, + ): ITransaction { + if (!coordinator.hasLease(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?) { + stagedNavData.addBreadcrumb(coordinator.hasLease(owner)) { + delegate.addBreadcrumb(breadcrumb, hint) + } + } + + override fun addBreadcrumb(breadcrumb: Breadcrumb) { + stagedNavData.addBreadcrumb(coordinator.hasLease(owner)) { + delegate.addBreadcrumb(breadcrumb) + } + } + + /** + * Releases the lease, clears remaining published state, and finishes this instance's transaction. + */ + fun dispose() { + attachedLifecycle?.removeObserver(this) + attachedLifecycle = null + + delegate.configureScope { coordinator.unregisterCandidate(owner, it)?.invoke() } + } + + private fun publishStagedNavData() { + delegate.configureScope { scope -> + if (!coordinator.hasLease(owner)) { + return@configureScope + } + stagedNavData.publishTo(scope) + } + + stagedNavData.publishPendingBreadcrumb(coordinator.hasLease(owner)) + } + + private inner class LeasedNavigationScope(private val realScope: IScope) : IScope by realScope { + + override fun getScreen(): String? = + stagedNavData.getScreen(realScope, coordinator.hasLease(owner)) + + override fun setScreen(screen: String?) { + stagedNavData.setScreen(screen, realScope, coordinator.hasLease(owner)) + } + + override fun setContexts(key: String?, value: Any?) { + if (key != NAVIGATION_CONTEXT_KEY) { + realScope.setContexts(key, value) + return + } + + stagedNavData.setNavigationContext(value, realScope, coordinator.hasLease(owner)) + } + + override fun getPropagationContext(): PropagationContext = + if (coordinator.hasLease(owner)) { + realScope.propagationContext + } else { + stagedNavData.propagationContext + } + + override fun setPropagationContext(propagationContext: PropagationContext) { + stagedNavData.propagationContext = propagationContext + if (coordinator.hasLease(owner)) { + realScope.propagationContext = propagationContext + } + } + + override fun withPropagationContext( + callback: Scope.IWithPropagationContext + ): PropagationContext = + if (coordinator.hasLease(owner)) { + realScope.withPropagationContext(callback).also { stagedNavData.propagationContext = it } + } else { + stagedNavData.withPropagationContext(callback) + } + } +} + +/** Holds navigation data staged for publication. */ +private class StagedNavData { + + private val scope = Scope(SentryOptions.empty()) + private var hasPropagationContextUpdate = false + + // These may differ from the staged values after background updates. Keep the exact published + // references so cleanup preserves host replacements, including structurally equal replacements. + private var publishedScreen: String? = null + private var publishedNavigationContext: Any? = null + + /** + * 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 i) before the first breadcrumb is published and ii) while not + * holding the navigation lease. + * + * This class implements a breadcrumb buffering policy that: + * + * 1. tracks the latest breadcrumb received prior to initial navigation 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 + * navigation lease isn't held. + * + * That policy gives us the following behavior: + * + * - Breadcrumbs staged before initial lease acquisition retain only the latest destination. + * Normally the first prepared update acquires the lease before emitting its breadcrumb, so it + * is published immediately. + * + * - Suppose the `SentryNavEffect` that publishes `Profile` pauses and loses the navigation 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 var pendingInitialBreadcrumb: (() -> Unit)? = null + + /** + * Whether this instance has ever published a breadcrumb. + * + * This remains true across pauses, so breadcrumbs received without the navigation 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 = PropagationContext(value) + hasPropagationContextUpdate = true + } + + fun getScreen(realScope: IScope, ownsLease: Boolean): String? = + if (ownsLease) realScope.screen else screenName + + fun setScreen(screen: String?, realScope: IScope, ownsLease: Boolean) { + scope.screen = screen + hasScreenUpdate = true + + if (ownsLease) { + publishScreen(screen, realScope) + } + } + + fun setNavigationContext(value: Any?, realScope: IScope, ownsLease: Boolean) { + scope.setContexts(NAVIGATION_CONTEXT_KEY, value) + hasNavigationContextUpdate = true + + if (ownsLease) { + publishNavigationContext(value, realScope) + } + } + + 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. + publishScreen(if (hasScreenUpdate) screenName else null, realScope) + publishNavigationContext( + if (hasNavigationContextUpdate) contexts[NAVIGATION_CONTEXT_KEY] else null, + realScope, + ) + if (hasPropagationContextUpdate) { + realScope.propagationContext = PropagationContext(scope.propagationContext) + } + } + + private fun publishScreen(screen: String?, realScope: IScope) { + publishedScreen = screen + realScope.screen = screen + } + + private fun publishNavigationContext(value: Any?, realScope: IScope) { + publishedNavigationContext = value + realScope.setContexts(NAVIGATION_CONTEXT_KEY, value) + } + + /** Called only for the last publishing session; releasing its lease alone does not clear data. */ + fun clearPublishedFrom(realScope: IScope) { + if (publishedScreen != null && realScope.screen === publishedScreen) { + realScope.screen = null + } + if ( + publishedNavigationContext != null && + realScope.contexts[NAVIGATION_CONTEXT_KEY] === publishedNavigationContext + ) { + // Use setContexts so scope observers also receive the removal. + realScope.setContexts(NAVIGATION_CONTEXT_KEY, null as Any?) + } + publishedScreen = null + publishedNavigationContext = null + } + + fun withPropagationContext(callback: Scope.IWithPropagationContext): PropagationContext = + scope.withPropagationContext(callback).also { hasPropagationContextUpdate = true } + + /** Publishes the breadcrumb when leased, or buffers it until the first publication. */ + fun addBreadcrumb(ownsLease: Boolean, publish: () -> Unit) { + if (ownsLease) { + 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 navigation lease. + */ + fun publishPendingBreadcrumb(ownsLease: Boolean) { + val pending = pendingInitialBreadcrumb ?: return + if (ownsLease) { + pending() + hasPublishedBreadcrumb = true + pendingInitialBreadcrumb = null + } + } +} + +/** One navigation owner, with transaction lifetimes tracked separately for handoff and rollback. */ +// Keep lease transitions and transaction cleanup in one state machine. +@Suppress("TooManyFunctions") +internal class NavLeaseCoordinator { + private class Candidate( + val owner: Any, + val publish: () -> Unit, + val clearPublishedData: (IScope) -> Unit, + ) { + var isStarted: Boolean = false + var resumedAt: Long = 0 + var isActive: Boolean = false + var isPaused: Boolean = false + var transaction: ITransaction? = null + } + + private val candidates = mutableListOf() + private var resumeSequence: Long = 0 + private var leaseHolder: Candidate? = null + + // Publication provenance survives pause, but grants no authority to write new data. + private var publishedDataCandidate: Candidate? = null + + // A paused session can retain its running transaction even when nobody holds the lease. + // Other candidates' transactions remain detached until restored, replaced, or disposed. + private var activeTransactionCandidate: Candidate? = null + + fun hasLease(owner: Any): Boolean = leaseHolder?.owner === owner + + fun registerCandidate( + owner: Any, + isResumed: Boolean, + isStarted: Boolean = isResumed, + clearPublishedData: (IScope) -> Unit = {}, + publish: () -> Unit, + ) { + if (findCandidate(owner) == null) { + candidates += + Candidate(owner, publish, clearPublishedData).apply { + this.isStarted = isStarted + resumedAt = if (isResumed) ++resumeSequence else 0 + } + } + } + + /** Acquires authority on the first prepared update, never on subsequent background updates. */ + fun activateCandidate(owner: Any, scope: IScope): (() -> Unit)? { + val candidate = findCandidate(owner) ?: return null + if (candidate.isActive) { + return null + } + candidate.isActive = true + return if (candidate.isPaused) null else transferLeaseTo(candidate, scope) + } + + fun resumeCandidate(owner: Any, scope: IScope): (() -> Unit)? { + val candidate = findCandidate(owner) ?: return null + candidate.isStarted = true + candidate.isPaused = false + candidate.resumedAt = ++resumeSequence + return if (candidate.isActive) transferLeaseTo(candidate, scope) else null + } + + fun pauseCandidate(owner: Any, scope: IScope): (() -> Unit)? { + val candidate = findCandidate(owner) ?: return null + candidate.isPaused = true + return if (leaseHolder === candidate) { + transferLeaseTo(mostRecentlyResumedCandidate(excluding = candidate), scope) + } else null + } + + fun startCandidate(owner: Any) { + // Becoming visible restores fallback eligibility, but only resume or explicit restoration + // grants navigation authority. A never-resumed incoming session is not a rollback target. + findCandidate(owner)?.isStarted = true + } + + fun stopCandidate(owner: Any, scope: IScope): (() -> Unit)? { + val candidate = findCandidate(owner) ?: return null + candidate.isStarted = false + candidate.isPaused = true + return if (leaseHolder === candidate) transferLeaseTo(mostRecentlyResumedCandidate(), scope) + else null + } + + fun unregisterCandidate(owner: Any, scope: IScope): (() -> Unit)? { + val candidate = findCandidate(owner) ?: return null + finishTransaction(candidate, scope) + candidates.remove(candidate) + if (publishedDataCandidate === candidate) { + publishedDataCandidate = null + candidate.clearPublishedData(scope) + } + return if (leaseHolder === candidate) transferLeaseTo(mostRecentlyResumedCandidate(), scope) + else null + } + + fun bindTransaction(owner: Any, transaction: ITransaction) { + val candidate = leaseHolder ?: return + if (candidate.owner === owner) { + candidate.transaction = transaction + activeTransactionCandidate = candidate + } + } + + private fun findCandidate(owner: Any): Candidate? = candidates.firstOrNull { it.owner === owner } + + private fun mostRecentlyResumedCandidate(excluding: Candidate? = null): Candidate? = + candidates + .filter { it !== excluding && it.isActive && it.isStarted && it.resumedAt > 0 } + .maxByOrNull { it.resumedAt } + + private fun transferLeaseTo(candidate: Candidate?, scope: IScope): (() -> Unit)? { + if (leaseHolder === candidate) { + return null + } + + if (candidate != null) { + val previous = activeTransactionCandidate + if (previous != null && previous !== candidate) { + clearTransactionFromScope(previous, scope) + activeTransactionCandidate = null + } + + val transaction = candidate.transaction + if (transaction?.isFinished == true) { + clearTransactionFromScope(candidate, scope) + candidate.transaction = null + if (activeTransactionCandidate === candidate) { + activeTransactionCandidate = null + } + } else if (transaction != null) { + scope.withTransaction { current -> + if (current == null || current === transaction) { + scope.transaction = transaction + activeTransactionCandidate = candidate + } + } + } + } + + leaseHolder = candidate + return candidate?.let { + { + if (leaseHolder === candidate) { + publishedDataCandidate = candidate + candidate.publish() + } + } + } + } + + private fun finishTransaction(candidate: Candidate, scope: IScope) { + val transaction = candidate.transaction + if (transaction != null && !transaction.isFinished) { + transaction.finish(transaction.status ?: SpanStatus.OK) + } + clearTransactionFromScope(candidate, scope) + candidate.transaction = null + if (activeTransactionCandidate === candidate) { + activeTransactionCandidate = null + } + } + + private fun clearTransactionFromScope(candidate: Candidate, scope: IScope) { + val transaction = candidate.transaction ?: return + scope.withTransaction { current -> + if (current === transaction) { + scope.clearTransaction() + } + } + } +} + +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..7bca7bc648 --- /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() { + // Clear only data still belonging to this session and restore any eligible previous owner. + // Observer cleanup then runs without the lease, so it cannot clear host or restored state. + navScopes.dispose() + observer.cleanup() + } +} 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 8b6269752b..eb5a2b323b 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 @@ -66,9 +67,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 }, ) } + // Register first so the initial prepared back stack update can acquire navigation ownership. + 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..9b58537801 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/INavScopesTest.kt @@ -0,0 +1,1077 @@ +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 `initial back stack update publishes navigation state before lifecycle resumes`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(state = Lifecycle.State.CREATED) + fixture.scope.screen = "visible" + + navScopes.attach(owner.lifecycle) + navScopes.runBackStackUpdate { + navScopes.configureScope { + it.screen = "pending" + it.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "pending")) + } + } + + 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.runBackStackUpdate { 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.runBackStackUpdate { + 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 publishes nav state after its initial update before lifecycle resumes`() { + 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.runBackStackUpdate { + 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.runBackStackUpdate { + secondNavScopes.configureScope { + it.screen = "Settings" + it.setContexts(NAVIGATION_CONTEXT_KEY, settingsBackStack) + } + } + + assertThat(fixture.scope.screen).isEqualTo("Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isEqualTo(settingsBackStack) + + 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.runBackStackUpdate { + 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.runBackStackUpdate { + 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 `disposing incoming owner restores previous owners complete navigation state`() { + val fixture = NavTestFixture() + val previousNavScopes = fixture.navScopes() + previousNavScopes.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + previousNavScopes.runBackStackUpdate { + previousNavScopes.configureScope { it.screen = "Home" } + } + val incomingNavScopes = fixture.navScopes() + incomingNavScopes.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + incomingNavScopes.runBackStackUpdate { + incomingNavScopes.configureScope { + it.screen = "Settings" + it.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Settings")) + } + } + assertThat(fixture.scope.screen).isEqualTo("Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("route" to "Settings")) + + incomingNavScopes.dispose() + + assertThat(fixture.scope.screen).isEqualTo("Home") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @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")) + + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + navScopes.runBackStackUpdate { navScopes.configureScope {} } + + 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 `disposing an older publisher preserves identical state published by a later paused owner`() { + val fixture = NavTestFixture() + val first = fixture.navScopes() + val firstOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + val second = fixture.navScopes() + val secondOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + val sharedContext = mapOf("route" to "Home") + first.attach(firstOwner.lifecycle) + first.runBackStackUpdate { + first.configureScope { + it.screen = "Home" + it.setContexts(NAVIGATION_CONTEXT_KEY, sharedContext) + } + } + second.attach(secondOwner.lifecycle) + second.runBackStackUpdate { + second.configureScope { + it.screen = "Home" + it.setContexts(NAVIGATION_CONTEXT_KEY, sharedContext) + } + } + firstOwner.stop() + secondOwner.pause() + + first.dispose() + + assertThat(fixture.scope.screen).isEqualTo("Home") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(sharedContext) + + second.dispose() + + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `disposing before the first prepared update preserves existing state without publishing pending breadcrumbs`() { + val fixture = NavTestFixture() + fixture.scope.screen = "Host" + val hostContext = mapOf("route" to "Host") + fixture.scope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + fixture.scopes.addBreadcrumb(Breadcrumb().apply { message = "Host" }) + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + navScopes.addBreadcrumb(Breadcrumb().apply { message = "Pending" }) + + navScopes.dispose() + + assertThat(fixture.scope.screen).isEqualTo("Host") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + assertThat(fixture.scope.breadcrumbs.map { it.message }).containsExactly("Host") + } + + @Test + fun `cleared navigation state is published as empty state when lifecycle resumes`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + + navScopes.attach(owner.lifecycle) + navScopes.runBackStackUpdate { + navScopes.configureScope { + it.screen = "Home" + it.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Home")) + } + } + owner.pause() + 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 after the first back stack update`() { + 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() + + navScopes.runBackStackUpdate { navScopes.configureScope {} } + + 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.runBackStackUpdate { + navScopes.configureScope {} + 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.runBackStackUpdate { + navScopes.configureScope {} + 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 navigation lease publishes breadcrumbs during overlap`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + val firstOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + firstNavScopes.attach(firstOwner.lifecycle) + firstNavScopes.runBackStackUpdate { + firstNavScopes.configureScope {} + firstNavScopes.addBreadcrumb(Breadcrumb().apply { message = "Home" }) + } + val secondNavScopes = fixture.navScopes() + val secondOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + secondNavScopes.attach(secondOwner.lifecycle) + + secondNavScopes.runBackStackUpdate { + secondNavScopes.configureScope {} + secondNavScopes.addBreadcrumb(Breadcrumb().apply { message = "Settings" }, Hint()) + } + firstNavScopes.addBreadcrumb(Breadcrumb().apply { message = "unseen" }, 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 navigation 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() + firstNavScopes.runBackStackUpdate { firstNavScopes.configureScope {} } + + assertThat(fixture.scope.breadcrumbs.map { it.message }).containsExactly("first latest") + + firstOwner.pause() + secondOwner.resume() + secondNavScopes.runBackStackUpdate { secondNavScopes.configureScope {} } + + assertThat(fixture.scope.breadcrumbs.map { it.message }) + .containsExactly("first latest", "second latest") + .inOrder() + } + + @Test + fun `navigation transactions cannot start without the navigation 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) + navScopes.runBackStackUpdate { navScopes.configureScope {} } + + 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 cannot start while lifecycle is paused`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + navScopes.attach(owner.lifecycle) + navScopes.runBackStackUpdate { navScopes.configureScope {} } + + owner.pause() + val transaction = + navScopes.startTransaction(TransactionContext("Profile", "navigation"), TransactionOptions()) + + assertThat(transaction.isNoOp).isTrue() + } + + @Test + fun `navigation transactions cannot start after another instance takes the navigation lease`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + firstNavScopes.attach(NavTestLifecycle().lifecycle) + firstNavScopes.runBackStackUpdate { firstNavScopes.configureScope {} } + 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 } + + 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) + firstNavScopes.runBackStackUpdate { firstNavScopes.configureScope {} } + 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) + firstNavScopes.runBackStackUpdate { firstNavScopes.configureScope {} } + 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 suspends and unbinds the previous transaction before applying the update`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + firstNavScopes.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + firstNavScopes.runBackStackUpdate { firstNavScopes.configureScope {} } + 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 previousWasSuspendedDuringUpdate = false + var scopeWasEmptyDuringUpdate = false + var replacementTransaction: ITransaction? = null + + secondNavScopes.runBackStackUpdate { + secondNavScopes.configureScope { + previousWasSuspendedDuringUpdate = !previousTransaction.isFinished + scopeWasEmptyDuringUpdate = it.transaction == null + replacementTransaction = + secondNavScopes.startTransaction( + TransactionContext("Settings", "navigation"), + TransactionOptions(), + ) + it.transaction = replacementTransaction + } + } + + assertThat(previousWasSuspendedDuringUpdate).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 `disposing incoming owner restores the previous owners suspended transaction`() { + val fixture = NavTestFixture() + val previousOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + val previousNavScopes = fixture.navScopes() + previousNavScopes.attach(previousOwner.lifecycle) + previousNavScopes.runBackStackUpdate { previousNavScopes.configureScope {} } + val previousTransaction = + previousNavScopes.startTransaction( + TransactionContext("Home", "navigation"), + TransactionOptions(), + ) + previousNavScopes.configureScope { it.transaction = previousTransaction } + val incomingNavScopes = fixture.navScopes() + incomingNavScopes.attach(NavTestLifecycle().lifecycle) + lateinit var incomingTransaction: ITransaction + incomingNavScopes.runBackStackUpdate { + incomingNavScopes.configureScope { + incomingTransaction = + incomingNavScopes.startTransaction( + TransactionContext("Settings", "navigation"), + TransactionOptions(), + ) + it.transaction = incomingTransaction + } + } + + incomingNavScopes.dispose() + + assertThat(incomingTransaction.isFinished).isTrue() + assertThat(previousTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(previousTransaction) + } + + @Test + fun `stopping previous owner retains its suspended transaction until it returns`() { + val fixture = NavTestFixture() + val previousOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + val previousNavScopes = fixture.navScopes() + previousNavScopes.attach(previousOwner.lifecycle) + previousNavScopes.runBackStackUpdate { previousNavScopes.configureScope {} } + val previousTransaction = + previousNavScopes.startTransaction( + TransactionContext("Home", "navigation"), + TransactionOptions(), + ) + previousNavScopes.configureScope { it.transaction = previousTransaction } + val incomingNavScopes = fixture.navScopes() + incomingNavScopes.attach(NavTestLifecycle().lifecycle) + lateinit var incomingTransaction: ITransaction + incomingNavScopes.runBackStackUpdate { + incomingNavScopes.configureScope { + incomingTransaction = + incomingNavScopes.startTransaction( + TransactionContext("Settings", "navigation"), + TransactionOptions(), + ) + it.transaction = incomingTransaction + } + } + + previousOwner.stop() + incomingNavScopes.dispose() + + assertThat(previousTransaction.isFinished).isFalse() + assertThat(incomingTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + + previousOwner.resume() + + assertThat(fixture.scope.transaction).isSameInstanceAs(previousTransaction) + } + + @Test + fun `pausing before incoming update retains the existing transaction without navigation authority`() { + val fixture = NavTestFixture() + val previousOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + val previousNavScopes = fixture.navScopes() + previousNavScopes.attach(previousOwner.lifecycle) + previousNavScopes.runBackStackUpdate { previousNavScopes.configureScope {} } + val previousTransaction = + previousNavScopes.startTransaction( + TransactionContext("Home", "navigation"), + TransactionOptions(), + ) + previousNavScopes.configureScope { it.transaction = previousTransaction } + val incomingNavScopes = fixture.navScopes() + incomingNavScopes.attach(NavTestLifecycle().lifecycle) + + previousOwner.pause() + incomingNavScopes.dispose() + + assertThat(previousTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(previousTransaction) + assertThat( + previousNavScopes + .startTransaction( + TransactionContext("Profile", "navigation"), + TransactionOptions(), + ) + .isNoOp + ) + .isTrue() + } + + @Test + fun `navigation transaction disposal finishes and unbinds the owned transaction`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle().lifecycle) + navScopes.runBackStackUpdate { navScopes.configureScope {} } + 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) + navScopes.runBackStackUpdate { navScopes.configureScope {} } + 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) + firstNavScopes.runBackStackUpdate { firstNavScopes.configureScope {} } + 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 stays with the active owner if the incoming owner disposes before an update`() { + val fixture = NavTestFixture() + val firstNavScopes = fixture.navScopes() + firstNavScopes.attach(NavTestLifecycle().lifecycle) + firstNavScopes.runBackStackUpdate { firstNavScopes.configureScope {} } + 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.State.RESUMED).lifecycle) + firstNavScopesInstance.runBackStackUpdate { + firstNavScopesInstance.configureScope { it.screen = "Home" } + } + 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) + assertThat(fixture.scope.screen).isEqualTo("Home") + } + + @Test + fun `navigation lease survives a no-op returned while tracing is disabled`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle().lifecycle) + navScopes.runBackStackUpdate { navScopes.configureScope {} } + 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 navigation lease`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle().lifecycle) + navScopes.runBackStackUpdate { navScopes.configureScope {} } + val incoming = fixture.navScopes() + incoming.attach(NavTestLifecycle().lifecycle) + incoming.runBackStackUpdate { incoming.configureScope {} } + 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 navigation lease is held`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle().lifecycle) + navScopes.runBackStackUpdate { navScopes.configureScope {} } + val context = PropagationContext() + + navScopes.configureScope { scope -> + scope.withPropagationContext { scope.propagationContext = context } + } + + assertThat(fixture.scope.propagationContext.traceId).isEqualTo(context.traceId) + } + + @Test + fun `navigation lease is not required for unrelated scope writes`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + navScopes.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + navScopes.runBackStackUpdate { navScopes.configureScope { it.screen = "Home" } } + val incoming = fixture.navScopes() + incoming.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + incoming.runBackStackUpdate { incoming.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 `unattached instance can make 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 `disposing paused previous owner preserves current lease`() { + val fixture = NavTestFixture() + val firstNavScopesInstance = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + firstNavScopesInstance.attach(owner.lifecycle) + firstNavScopesInstance.runBackStackUpdate { firstNavScopesInstance.configureScope {} } + + val secondNavScopesInstance = fixture.navScopes() + secondNavScopesInstance.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + secondNavScopesInstance.runBackStackUpdate { secondNavScopesInstance.configureScope {} } + + 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 the lease`() { + val fixture = NavTestFixture() + val navScopes = fixture.navScopes() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + + navScopes.attach(owner.lifecycle) + navScopes.runBackStackUpdate { 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) + navScopes.runBackStackUpdate { navScopes.configureScope {} } + 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) + navScopes.runBackStackUpdate { navScopes.configureScope {} } + 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..cb88fabdb6 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavLeaseCoordinatorTest.kt @@ -0,0 +1,368 @@ +package io.sentry.compose.navigation3 + +import com.google.common.truth.Truth.assertThat +import io.sentry.SpanStatus +import org.junit.Test + +class NavLeaseCoordinatorTest { + + @Test + fun `registration and resume do not preempt an owner before the first prepared update`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + fixture.activate(first) + val second = fixture.register(isResumed = false) + + fixture.coordinator.resumeCandidate(second, fixture.scope)?.invoke() + + assertThat(fixture.coordinator.hasLease(first)).isTrue() + assertThat(fixture.coordinator.hasLease(second)).isFalse() + assertThat(fixture.publications).containsExactly(first) + } + + @Test + fun `activation preempts current owner and publishes only after ownership changes`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + val second = fixture.register(isResumed = false) + + fixture.activate(first) + fixture.activate(first) + fixture.activate(second) + fixture.activate(first) + + assertThat(fixture.coordinator.hasLease(second)).isTrue() + assertThat(fixture.publications).containsExactly(first, second).inOrder() + } + + @Test + fun `unregistering current owner restores the most recently resumed eligible candidate`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + val second = fixture.register(isResumed = true) + val incoming = fixture.register(isResumed = false) + fixture.activate(first) + fixture.activate(second) + fixture.coordinator.resumeCandidate(first, fixture.scope)?.invoke() + fixture.activate(incoming) + + fixture.coordinator.unregisterCandidate(incoming, fixture.scope)?.invoke() + + assertThat(fixture.coordinator.hasLease(first)).isTrue() + assertThat(fixture.publications) + .containsExactly(first, second, first, incoming, first) + .inOrder() + } + + @Test + fun `stopped candidates are not restored on disposal`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + val incoming = fixture.register(isResumed = false) + fixture.activate(first) + fixture.activate(incoming) + fixture.coordinator.stopCandidate(first, fixture.scope)?.invoke() + + fixture.coordinator.unregisterCandidate(incoming, fixture.scope)?.invoke() + + assertThat(fixture.coordinator.hasLease(first)).isFalse() + assertThat(fixture.coordinator.hasLease(incoming)).isFalse() + } + + @Test + fun `first update while paused waits for resume to acquire the lease`() { + val fixture = Fixture() + val owner = fixture.register(isResumed = true) + fixture.coordinator.pauseCandidate(owner, fixture.scope)?.invoke() + + fixture.activate(owner) + + assertThat(fixture.coordinator.hasLease(owner)).isFalse() + assertThat(fixture.publications).isEmpty() + + fixture.coordinator.resumeCandidate(owner, fixture.scope)?.invoke() + + assertThat(fixture.coordinator.hasLease(owner)).isTrue() + assertThat(fixture.publications).containsExactly(owner) + } + + @Test + fun `stopping a restored paused candidate releases its lease`() { + val fixture = Fixture() + val outgoing = fixture.register(isResumed = true) + fixture.activate(outgoing) + fixture.coordinator.pauseCandidate(outgoing, fixture.scope)?.invoke() + val incoming = fixture.register(isResumed = false) + fixture.activate(incoming) + fixture.coordinator.unregisterCandidate(incoming, fixture.scope)?.invoke() + assertThat(fixture.coordinator.hasLease(outgoing)).isTrue() + + fixture.coordinator.stopCandidate(outgoing, fixture.scope)?.invoke() + + assertThat(fixture.coordinator.hasLease(outgoing)).isFalse() + assertThat(fixture.coordinator.hasLease(incoming)).isFalse() + } + + @Test + fun `restoration picks the most recently resumed visible candidate and skips stopped candidates`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + fixture.activate(first) + fixture.coordinator.pauseCandidate(first, fixture.scope)?.invoke() + val second = fixture.register(isResumed = true) + fixture.activate(second) + fixture.coordinator.pauseCandidate(second, fixture.scope)?.invoke() + fixture.coordinator.stopCandidate(second, fixture.scope)?.invoke() + val incoming = fixture.register(isResumed = false) + fixture.activate(incoming) + + fixture.coordinator.unregisterCandidate(incoming, fixture.scope)?.invoke() + + assertThat(fixture.coordinator.hasLease(first)).isTrue() + assertThat(fixture.coordinator.hasLease(second)).isFalse() + } + + @Test + fun `owners are distinguished by identity even when structurally equal`() { + val fixture = Fixture() + val first = listOf("same") + val second = listOf("same") + fixture.coordinator.registerCandidate(first, isResumed = false) {} + fixture.activate(first) + + fixture.coordinator.unregisterCandidate(second, fixture.scope) + fixture.coordinator.bindTransaction(second, fixture.sdk.transaction()) + + assertThat(fixture.coordinator.hasLease(first)).isTrue() + assertThat(fixture.coordinator.hasLease(second)).isFalse() + } + + @Test + fun `handoff detaches transaction and disposal restores it before publishing`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + fixture.activate(first) + val tx = fixture.bind(first) + tx.status = SpanStatus.CANCELLED + val incoming = fixture.register(isResumed = false) + + fixture.activate(incoming) + + assertThat(tx.isFinished).isFalse() + assertThat(tx.status).isEqualTo(SpanStatus.CANCELLED) + assertThat(fixture.scope.transaction).isNull() + + fixture.coordinator.unregisterCandidate(incoming, fixture.scope)?.invoke() + + assertThat(fixture.coordinator.hasLease(first)).isTrue() + assertThat(tx.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(tx) + } + + @Test + fun `stopping a displaced owner retains its transaction without restoring its lease`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + fixture.activate(first) + val tx = fixture.bind(first) + val incoming = fixture.register(isResumed = false) + fixture.activate(incoming) + + fixture.coordinator.stopCandidate(first, fixture.scope)?.invoke() + fixture.coordinator.unregisterCandidate(incoming, fixture.scope)?.invoke() + + assertThat(tx.isFinished).isFalse() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.coordinator.hasLease(first)).isFalse() + + fixture.coordinator.resumeCandidate(first, fixture.scope)?.invoke() + + assertThat(fixture.scope.transaction).isSameInstanceAs(tx) + } + + @Test + fun `handoff after the outgoing owner stopped retains its transaction for return`() { + val fixture = Fixture() + val outgoing = fixture.register(isResumed = true) + fixture.activate(outgoing) + val transaction = fixture.bind(outgoing) + fixture.coordinator.stopCandidate(outgoing, fixture.scope)?.invoke() + val incoming = fixture.register(isResumed = false) + + fixture.activate(incoming) + + assertThat(transaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isNull() + + fixture.coordinator.unregisterCandidate(incoming, fixture.scope)?.invoke() + fixture.coordinator.resumeCandidate(outgoing, fixture.scope)?.invoke() + + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + } + + @Test + fun `pause without a replacement retains existing transaction but releases authority`() { + val fixture = Fixture() + val owner = fixture.register(isResumed = true) + fixture.activate(owner) + val tx = fixture.bind(owner) + + fixture.coordinator.pauseCandidate(owner, fixture.scope)?.invoke() + + assertThat(fixture.coordinator.hasLease(owner)).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(tx) + assertThat(tx.isFinished).isFalse() + + fixture.coordinator.resumeCandidate(owner, fixture.scope)?.invoke() + + assertThat(fixture.coordinator.hasLease(owner)).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(tx) + } + + @Test + fun `incoming update detaches the retained transaction of an already paused owner`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + fixture.activate(first) + val tx = fixture.bind(first) + fixture.coordinator.pauseCandidate(first, fixture.scope)?.invoke() + + fixture.activate(fixture.register(isResumed = false)) + + assertThat(tx.isFinished).isFalse() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `pausing current owner restores both authority and the previous transaction`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + fixture.activate(first) + val firstTx = fixture.bind(first) + val second = fixture.register(isResumed = true) + fixture.activate(second) + val secondTx = fixture.bind(second) + + fixture.coordinator.pauseCandidate(second, fixture.scope)?.invoke() + + assertThat(secondTx.isFinished).isFalse() + assertThat(firstTx.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(firstTx) + assertThat(fixture.coordinator.hasLease(first)).isTrue() + assertThat(fixture.publications).containsExactly(first, second, first).inOrder() + } + + @Test + fun `resuming displaced owner swaps transactions and allows rollback`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + fixture.activate(first) + val firstTx = fixture.bind(first) + val second = fixture.register(isResumed = true) + fixture.activate(second) + val secondTx = fixture.bind(second) + + fixture.coordinator.resumeCandidate(first, fixture.scope)?.invoke() + + assertThat(fixture.scope.transaction).isSameInstanceAs(firstTx) + assertThat(secondTx.isFinished).isFalse() + assertThat(fixture.coordinator.hasLease(first)).isTrue() + + fixture.coordinator.unregisterCandidate(first, fixture.scope)?.invoke() + + assertThat(firstTx.isFinished).isTrue() + assertThat(secondTx.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(secondTx) + assertThat(fixture.coordinator.hasLease(second)).isTrue() + } + + @Test + fun `host transaction is preserved during handoff restoration and disposal`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + fixture.activate(first) + val navTx = fixture.bind(first) + val hostTx = fixture.sdk.transaction("host") + fixture.scope.transaction = hostTx + val second = fixture.register(isResumed = false) + + fixture.activate(second) + fixture.coordinator.stopCandidate(first, fixture.scope)?.invoke() + fixture.coordinator.unregisterCandidate(second, fixture.scope)?.invoke() + fixture.coordinator.resumeCandidate(first, fixture.scope)?.invoke() + + assertThat(fixture.coordinator.hasLease(first)).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTx) + assertThat(navTx.isFinished).isFalse() + + fixture.coordinator.unregisterCandidate(first, fixture.scope)?.invoke() + + assertThat(navTx.isFinished).isTrue() + assertThat(hostTx.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTx) + } + + @Test + fun `transaction finished while its owner is stopped is discarded on return`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + fixture.activate(first) + val tx = fixture.bind(first) + val second = fixture.register(isResumed = false) + fixture.activate(second) + fixture.coordinator.stopCandidate(first, fixture.scope)?.invoke() + tx.finish() + + fixture.coordinator.unregisterCandidate(second, fixture.scope)?.invoke() + fixture.coordinator.resumeCandidate(first, fixture.scope)?.invoke() + + assertThat(fixture.coordinator.hasLease(first)).isTrue() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `disposing a former owner leaves current authority and transaction intact`() { + val fixture = Fixture() + val first = fixture.register(isResumed = true) + fixture.activate(first) + val firstTx = fixture.bind(first) + val second = fixture.register(isResumed = false) + fixture.activate(second) + val secondTx = fixture.bind(second) + fixture.coordinator.stopCandidate(first, fixture.scope)?.invoke() + + assertThat(firstTx.isFinished).isFalse() + + fixture.coordinator.unregisterCandidate(first, fixture.scope)?.invoke() + + assertThat(firstTx.isFinished).isTrue() + assertThat(secondTx.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(secondTx) + assertThat(fixture.coordinator.hasLease(second)).isTrue() + } + + private class Fixture { + val sdk = NavTestFixture() + val coordinator = sdk.coordinator + val scope = sdk.scope + val publications = mutableListOf() + + fun register(isResumed: Boolean): Any = + Any().also { owner -> + coordinator.registerCandidate(owner, isResumed) { + assertThat(coordinator.hasLease(owner)).isTrue() + publications += owner + } + } + + fun activate(owner: Any) { + coordinator.activateCandidate(owner, scope)?.invoke() + } + + fun bind(owner: Any) = + sdk.transaction().also { + coordinator.bindTransaction(owner, it) + scope.transaction = it + } + } +} 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..04f471f5c1 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavSessionTest.kt @@ -0,0 +1,645 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class NavSessionTest { + + @Test + fun `abandoned activity launch restores the paused outgoing session before it stops`() { + val fixture = NavTestFixture() + val outgoing = fixture.session() + val outgoingOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + outgoing.attach(outgoingOwner.lifecycle) + outgoing.onBackStackChanged(listOf("Home")) + val transaction = requireNotNull(fixture.scope.transaction) + outgoingOwner.pause() + val incoming = fixture.session() + incoming.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + incoming.onBackStackChanged(listOf("Settings")) + assertThat(transaction.isFinished).isFalse() + + incoming.dispose() + + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + 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", "/Settings") + .inOrder() + + outgoing.onBackStackChanged(listOf("Profile")) + + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.screen).isEqualTo("/Profile") + } + + @Test + fun `the initial destination publishes navigation data 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).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 `updates after eager publication emit data before the owner resumes`() { + 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.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.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 suspends previous txn and replaces navigation data before resume`() { + 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).isFalse() + assertThat(fixture.scope.transaction?.name).isEqualTo("/Settings") + 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("/Home", "/Settings") + .inOrder() + } + + @Test + fun `disposing incoming session restores still-resumed previous session data`() { + val fixture = NavTestFixture() + val previousSession = fixture.session() + val previousOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + previousSession.attach(previousOwner.lifecycle) + previousSession.onBackStackChanged(listOf("Home")) + val incomingSession = fixture.session() + incomingSession.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + incomingSession.onBackStackChanged(listOf("Settings")) + assertThat(fixture.scope.screen).isEqualTo("/Settings") + + incomingSession.dispose() + + 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", "/Settings") + .inOrder() + } + + @Test + fun `disposing incoming session restores the previous transaction`() { + 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.onBackStackChanged(listOf("Settings")) + val incomingTransaction = requireNotNull(fixture.scope.transaction) + + incomingSession.dispose() + + assertThat(previousTransaction.isFinished).isFalse() + assertThat(incomingTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(previousTransaction) + + previousSession.onBackStackChanged(listOf("Home", "Profile")) + + val restoredOwnerTransaction = requireNotNull(fixture.scope.transaction) + assertThat(previousTransaction.isFinished).isTrue() + assertThat(restoredOwnerTransaction).isNotSameInstanceAs(previousTransaction) + assertThat(restoredOwnerTransaction.name).isEqualTo("/Profile") + } + + @Test + fun `disposing incoming session does not restore previous session after it stops`() { + val fixture = NavTestFixture() + val previousSession = fixture.session() + val previousOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + previousSession.attach(previousOwner.lifecycle) + previousSession.onBackStackChanged(listOf("Home")) + val previousTransaction = requireNotNull(fixture.scope.transaction) + val incomingSession = fixture.session() + incomingSession.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + incomingSession.onBackStackChanged(listOf("Settings")) + + previousOwner.stop() + incomingSession.dispose() + + assertThat(previousTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `resuming previous session reclaims navigation authority without replaying its breadcrumb`() { + val fixture = NavTestFixture() + val previousSession = fixture.session() + val previousOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + previousSession.attach(previousOwner.lifecycle) + previousSession.onBackStackChanged(listOf("Home")) + previousOwner.pause() + val incomingSession = fixture.session() + incomingSession.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + incomingSession.onBackStackChanged(listOf("Settings")) + + previousOwner.resume() + + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + 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", "/Settings") + .inOrder() + + previousSession.onBackStackChanged(listOf("Profile")) + + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.screen).isEqualTo("/Profile") + } + + @Test + fun `outgoing session can continue navigating until the incoming update is prepared`() { + val fixture = NavTestFixture() + val outgoing = fixture.session() + outgoing.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + outgoing.onBackStackChanged(listOf("Home")) + val incoming = fixture.session() + incoming.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + + outgoing.onBackStackChanged(listOf("Profile")) + + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Profile") + .inOrder() + + incoming.onBackStackChanged(listOf("Settings")) + + assertThat(fixture.scope.transaction?.name).isEqualTo("/Settings") + assertThat(fixture.scope.screen).isEqualTo("/Settings") + } + + @Test + fun `pausing incoming session restores previous transaction data and authority together`() { + val fixture = NavTestFixture() + val previous = fixture.session() + previous.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + previous.onBackStackChanged(listOf("Home")) + val previousTransaction = requireNotNull(fixture.scope.transaction) + val incoming = fixture.session() + val incomingOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + incoming.attach(incomingOwner.lifecycle) + incoming.onBackStackChanged(listOf("Settings")) + val incomingTransaction = requireNotNull(fixture.scope.transaction) + + incomingOwner.pause() + + assertThat(incomingTransaction.isFinished).isFalse() + assertThat(previousTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(previousTransaction) + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Home")))) + + previous.onBackStackChanged(listOf("Profile")) + incoming.onBackStackChanged(listOf("Hidden")) + + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Settings", "/Profile") + .inOrder() + } + + @Test + fun `paused session stages navigation without creating a new transaction`() { + 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.pause() + + assertThat(transaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + + session.onBackStackChanged(listOf("Profile")) + + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + + owner.resume() + + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + + session.onBackStackChanged(listOf("Settings")) + + assertThat(fixture.scope.transaction?.name).isEqualTo("/Settings") + assertThat(fixture.scope.screen).isEqualTo("/Settings") + } + + @Test + fun `propagation context follows restored navigation ownership when transactions are disabled`() { + val fixture = NavTestFixture() + fixture.options.setTracesSampleRate(null) + val previous = fixture.session() + val previousOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + previous.attach(previousOwner.lifecycle) + previous.onBackStackChanged(listOf("Home")) + val homeTrace = fixture.scope.propagationContext.traceId + val incoming = fixture.session() + incoming.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + incoming.onBackStackChanged(listOf("Settings")) + val settingsTrace = fixture.scope.propagationContext.traceId + assertThat(settingsTrace).isNotEqualTo(homeTrace) + + incoming.dispose() + + assertThat(fixture.scope.propagationContext.traceId).isEqualTo(homeTrace) + assertThat(fixture.scope.screen).isEqualTo("/Home") + + previousOwner.pause() + previous.onBackStackChanged(listOf("Profile")) + + assertThat(fixture.scope.propagationContext.traceId).isEqualTo(homeTrace) + assertThat(fixture.scope.screen).isEqualTo("/Home") + + previousOwner.resume() + + assertThat(fixture.scope.propagationContext.traceId).isNotEqualTo(homeTrace) + assertThat(fixture.scope.propagationContext.traceId).isNotEqualTo(settingsTrace) + assertThat(fixture.scope.screen).isEqualTo("/Profile") + } + + @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 suspends the previous transaction`() { + 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.onBackStackChanged(emptyList()) + + assertThat(previousTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `incoming session with transactions disabled suspends previous txn and publishes 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(SentryNavOptions { enableNavigationTransactions = false }) + incomingSession.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + + incomingSession.onBackStackChanged(listOf("Settings")) + + assertThat(previousTransaction.isFinished).isFalse() + 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("/Home", "/Settings") + .inOrder() + } + + @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 clears navigation state and finishes its transaction while retaining breadcrumbs`() { + 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).isNull() + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + } + + @Test + fun `disposing a paused session clears its last published state rather than its staged destination`() { + val fixture = NavTestFixture() + val session = fixture.session() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + session.attach(owner.lifecycle) + session.onBackStackChanged(listOf("Home")) + + owner.pause() + session.onBackStackChanged(listOf("Hidden")) + + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Home")))) + + session.dispose() + + 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 `destroying an incoming owner before resume restores the previous sessions state and transaction`() { + val fixture = NavTestFixture() + val previous = fixture.session() + previous.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + previous.onBackStackChanged(listOf("Home")) + val previousTransaction = requireNotNull(fixture.scope.transaction) + val incoming = fixture.session() + val incomingOwner = NavTestLifecycle(Lifecycle.State.CREATED) + incoming.attach(incomingOwner.lifecycle) + incoming.onBackStackChanged(listOf("Settings")) + val incomingTransaction = requireNotNull(fixture.scope.transaction) + + incomingOwner.destroy() + incoming.dispose() + + assertThat(incomingTransaction.isFinished).isTrue() + assertThat(previousTransaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(previousTransaction) + 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", "/Settings") + .inOrder() + } + + @Test + fun `destroying an owner before resume clears its published state without a replacement`() { + val fixture = NavTestFixture() + val session = fixture.session() + val owner = NavTestLifecycle(Lifecycle.State.CREATED) + session.attach(owner.lifecycle) + session.onBackStackChanged(listOf("Home")) + + owner.destroy() + + 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 current session preserves navigation context replaced by the host`() { + val fixture = NavTestFixture() + val session = fixture.session() + session.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + session.onBackStackChanged(listOf("Home")) + // Even an equal replacement belongs to the host, not the navigation session. + val hostContext = mapOf("Back Stack" to listOf(mapOf("entry" to "/Home"))) + fixture.scope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + session.dispose() + + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `disposing current session preserves screen replaced by the host`() { + val fixture = NavTestFixture() + val session = fixture.session() + session.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + session.onBackStackChanged(listOf("Home")) + val hostScreen = String(charArrayOf('/', 'H', 'o', 'm', 'e')) + fixture.scope.screen = hostScreen + + session.dispose() + + assertThat(fixture.scope.screen).isSameInstanceAs(hostScreen) + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `destroying a paused owner preserves host replacements and later composition disposal is harmless`() { + val fixture = NavTestFixture() + val session = fixture.session() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + session.attach(owner.lifecycle) + session.onBackStackChanged(listOf("Home")) + owner.pause() + val hostContext = mapOf("route" to "Host") + fixture.scope.screen = "Host" + fixture.scope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + owner.destroy() + session.dispose() + + assertThat(fixture.scope.screen).isEqualTo("Host") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavSessionTransactionRetentionTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavSessionTransactionRetentionTest.kt new file mode 100644 index 0000000000..9f3682e90b --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavSessionTransactionRetentionTest.kt @@ -0,0 +1,92 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class NavSessionTransactionRetentionTest { + + @Test + fun `restarting a stopped session makes its unfinished transaction available for fallback`() { + val fixture = NavTestFixture() + val outgoing = fixture.session() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + outgoing.attach(owner.lifecycle) + outgoing.onBackStackChanged(listOf("Home")) + val transaction = requireNotNull(fixture.scope.transaction) + owner.pause() + val incoming = fixture.session() + incoming.attach(NavTestLifecycle(Lifecycle.State.CREATED).lifecycle) + incoming.onBackStackChanged(listOf("Settings")) + owner.stop() + + owner.start() + + assertThat(fixture.scope.screen).isEqualTo("/Settings") + incoming.dispose() + assertThat(transaction.isFinished).isFalse() + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + assertThat(fixture.scope.screen).isEqualTo("/Home") + } + + @Test + fun `returning after stop restores the original transaction until the next navigation change`() { + val fixture = NavTestFixture() + val outgoing = fixture.session() + val outgoingOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + outgoing.attach(outgoingOwner.lifecycle) + outgoing.onBackStackChanged(listOf("Home")) + val transaction = requireNotNull(fixture.scope.transaction) + outgoingOwner.pause() + val incoming = fixture.session() + val incomingOwner = NavTestLifecycle(Lifecycle.State.CREATED) + incoming.attach(incomingOwner.lifecycle) + incoming.onBackStackChanged(listOf("Settings")) + incomingOwner.resume() + + outgoingOwner.stop() + + assertThat(transaction.isFinished).isFalse() + incoming.dispose() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + + outgoingOwner.resume() + + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + val child = requireNotNull(fixture.scopes.span).startChild("return work") + assertThat(child.isNoOp).isFalse() + assertThat(child.spanContext.parentSpanId).isEqualTo(transaction.spanContext.spanId) + child.finish() + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Settings") + .inOrder() + outgoing.onBackStackChanged(listOf("Profile")) + assertThat(transaction.isFinished).isTrue() + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + } + + @Test + fun `back stack changes while stopped finish the retained transaction without creating a replacement`() { + val fixture = NavTestFixture() + val outgoing = fixture.session() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + outgoing.attach(owner.lifecycle) + outgoing.onBackStackChanged(listOf("Home")) + val transaction = requireNotNull(fixture.scope.transaction) + val incoming = fixture.session() + incoming.attach(NavTestLifecycle(Lifecycle.State.RESUMED).lifecycle) + incoming.onBackStackChanged(listOf("Settings")) + owner.stop() + + outgoing.onBackStackChanged(listOf("Profile")) + + assertThat(transaction.isFinished).isTrue() + assertThat(fixture.scope.transaction?.name).isEqualTo("/Settings") + incoming.dispose() + owner.resume() + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.transaction).isNull() + } +} 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..b56197ea12 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/NavTestFixture.kt @@ -0,0 +1,82 @@ +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 stop() { + lifecycle.currentState = Lifecycle.State.CREATED + } + + fun start() { + 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 7b8a4b9946..20c284d86f 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.DisposableEffect 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() @@ -348,6 +360,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -385,6 +398,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -417,6 +431,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -459,6 +474,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -496,6 +512,7 @@ class SentryNavEffectTest { options = options.value, backStackEntryMapper = defaultEntryMapper, scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -525,7 +542,9 @@ class SentryNavEffectTest { SentryNavEffect( backStack = backStack, backStackEntryMapper = defaultEntryMapper, + options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } } @@ -543,6 +562,70 @@ class SentryNavEffectTest { assertThat(fixture.scope.contexts.containsKey(NAVIGATION_CONTEXT_KEY)).isFalse() } + @Test + fun `effect publishes initial navigation before 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).isEqualTo("/Home") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + 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).isEqualTo("/Home") + originalOwner.pause() + originalOwner.resume() + assertThat(fixture.scope.screen).isEqualTo("/Home") + 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