diff --git a/CHANGELOG.md b/CHANGELOG.md index acca00fc3c..ffd5de689a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Improvements + +- Support Navigation 3 integration for multi-Activity and multi-Fragment applications, where each Activity or Fragment has its own `SentryNavEffect` ([#6305](https://github.com/getsentry/sentry-java/pull/6305)) + - See the [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups) for a detailed list of supported vs unsupported app setups. + ### Fixes - Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2afcc5d48b..af03ea7d4a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -116,6 +116,7 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidxNavigation" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3" } +androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version = "1.1.0" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" } diff --git a/sentry-android-navigation3/README.md b/sentry-android-navigation3/README.md index bdfec781fc..95bbbeb006 100644 --- a/sentry-android-navigation3/README.md +++ b/sentry-android-navigation3/README.md @@ -9,4 +9,4 @@ Because Nav3 back stacks can have arbitrary key types, host apps need to provide keys and the data Sentry should display for each (see `BackStackEntryMapper`). See the [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/) for additional details, including -installation, configuration, and limitations. +configuration, supported setups, and limitations. diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index bab8fce5c7..9844601f81 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -65,8 +65,10 @@ dependencies { compileOnly(libs.androidx.lifecycle.runtime.compose) testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.fragment.ktx) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.androidx.viewpager2) testImplementation(libs.google.truth) testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt index f567f7c8c5..6100c70620 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt @@ -395,7 +395,9 @@ internal data class NormalizedSentryBackStackEntry( val name: String, /** Sanitized [SentryBackStackEntry.arguments] (i.e., bounded in size and depth). */ val arguments: Map = emptyMap(), - /** The reason why the SDK dropped host-provided arguments. */ + /** + * The reason why the SDK dropped host-provided arguments, or null if no arguments were dropped. + */ val argumentDropReason: ArgumentDropReason? = null, ) { 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..8e2806686e 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 @@ -2,13 +2,11 @@ package io.sentry.compose.navigation3 import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.IScope -import io.sentry.IScopes import io.sentry.ITransaction -import io.sentry.PropagationContext import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.TransactionOptions @@ -16,6 +14,9 @@ import io.sentry.compose.navigation3.BackStackConverter.RetentionPolicy import io.sentry.compose.navigation3.PreparedChange.BackStackHasNewTop import io.sentry.compose.navigation3.PreparedChange.BackStackHasSameTop import io.sentry.compose.navigation3.PreparedChange.BackStackIsEmpty +import io.sentry.compose.navigation3.lifecycle.INavScope +import io.sentry.compose.navigation3.lifecycle.INavScopes +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY import io.sentry.protocol.App import io.sentry.protocol.TransactionNameSource import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion @@ -47,10 +48,11 @@ private const val NAVIGATION_OP: String = "navigation" * **Thread safety** * * This class is ***not*** thread-safe. Clients should serialize calls to [onBackStackChanged] and - * [cleanup] (e.g., via invocation from an `*Effect` or another form of thread confinement). + * [dispose] (e.g., via invocation from an `*Effect` or another form of thread confinement). */ +@Suppress("TooManyFunctions") internal class BackStackObserver( - private val scopes: IScopes, + private val scopes: INavScopes, private val options: SentryNavOptions, entryMapper: ForwardingBackStackEntryMapper, ) { @@ -110,23 +112,14 @@ internal class BackStackObserver( scopes.configureScope { scope -> applyChange(scope, change) } } - internal fun cleanup() { - previousTop = null - previousTopNormalized = null - - scopes.configureScope { scope -> - navTransaction.stop(scope) - navScreen.clear(scope) - - if (options.captureBackStack) { - // This observer owns the nav context while it's in the composition, and cleanup removes - // it to avoid leaking stale back stack data after observation stops. If the host app - // replaces one observer with another, there may be a brief gap where events lack nav - // context. Apps should keep the observer at the nav root so cleanup only runs when the - // navigation session is ending, not during normal destination changes. - navContext.clear(scope) - } - } + /** + * Clears the nav data this observer recorded. + * + * Call it when observation ends so that no stale screen, nav context, or nav transaction outlives + * it. + */ + internal fun dispose() { + scopes.configureScope { scope -> tearDown(scope) } } private fun prepareChange(backStack: List): PreparedChange { @@ -140,7 +133,7 @@ internal class BackStackObserver( } } - private fun applyChange(scope: IScope, change: PreparedChange) { + private fun applyChange(scope: INavScope, change: PreparedChange) { when (change) { is BackStackIsEmpty -> handleEmptyBackStack(scope) is BackStackHasNewTop -> handleNewTop(scope, change.previousTop, change.backStack) @@ -185,7 +178,7 @@ internal class BackStackObserver( } private fun handleNewTop( - scope: IScope, + scope: INavScope, previousTop: NormalizedSentryBackStackEntry?, currentBackStack: BackStackData, ) { @@ -212,23 +205,19 @@ internal class BackStackObserver( ) ?.let { transaction -> navContext.updateTransaction(transaction, scope, currentBackStack) } } else { - // Rotate the propagation context. - scope.withPropagationContext { scope.setPropagationContext(PropagationContext()) } + scope.rotatePropagationContext() } storeAsPreviousTop(currentBackStack.topEntry, currentBackStack.topEntryNormalized) } - private fun handleSameTop(scope: IScope, backStack: BackStackData) { + private fun handleSameTop(scope: INavScope, backStack: BackStackData) { navContext.update(scope, backStack.capturedEntriesNormalized) storeAsPreviousTop(backStack.topEntry, backStack.topEntryNormalized) } - private fun handleEmptyBackStack(scope: IScope) { - navTransaction.stop(scope) - navContext.clear(scope) - navScreen.clear(scope) - clearPreviousTop() + private fun handleEmptyBackStack(scope: INavScope) { + tearDown(scope) } private fun storeAsPreviousTop(topEntry: T, topEntryNormalized: NormalizedSentryBackStackEntry) { @@ -240,12 +229,19 @@ internal class BackStackObserver( previousTop = null previousTopNormalized = null } + + private fun tearDown(scope: INavScope) { + navTransaction.stop(scope) + navContext.clear(scope) + navScreen.clear(scope) + clearPreviousTop() + } } /** * A model for applying one back stack update. * - * Lets us separate change preparation from its application so that the [IScopes.configureScope] + * Lets us separate change preparation from its application so that the [INavScopes.configureScope] * callback in charge of application can use already-computed navigation state. Otherwise, any * exceptions thrown during state computation would be swallowed by `configureScope`'s over-broad * `catch` clause. @@ -281,7 +277,7 @@ private data class BackStackData( ) /** A helper class for managing nav transactions. */ -private class NavTransaction(private val scopes: IScopes) { +private class NavTransaction(private val scopes: INavScopes) { private companion object { private const val TRANSACTION_ORIGIN = "auto.navigation.nav3" @@ -291,11 +287,11 @@ private class NavTransaction(private val scopes: IScopes) { /** Starts an idle navigation transaction, or no-ops if another transaction is already active. */ fun start( - scope: IScope, + scope: INavScope, name: String, arguments: Map, ): ITransaction? { - clearTransactionIfFinished(scope) + scope.clearFinishedTransaction() if (scope.transaction != null) { scopes.options.logger.log( @@ -323,6 +319,7 @@ private class NavTransaction(private val scopes: IScopes) { transactionOptions, ) + // Not an error: INavScopes.startTransaction may decline to start a transaction by design. if (transaction.isNoOp) { return null } @@ -335,61 +332,39 @@ private class NavTransaction(private val scopes: IScopes) { } } - scope.withTransaction { tx -> - if (tx == null) { - scope.transaction = transaction - } - } + scope.bindTransaction(transaction) return transaction } /** Finishes and unsets the active navigation transaction, if one exists. */ - fun stop(scope: IScope) { + fun stop(scope: INavScope) { val transaction = activeNavTransaction ?: return val status = transaction.status ?: SpanStatus.OK transaction.finish(status) - - scope.withTransaction { tx -> - if (tx == transaction) { - scope.clearTransaction() - } - } - + scope.unbindTransaction(transaction) activeNavTransaction = null } - - /** Clears a stale finished transaction that's still bound to the default scope. */ - private fun clearTransactionIfFinished(scope: IScope) { - scope.withTransaction { tx -> - if (tx?.isFinished == true) { - scope.clearTransaction() - } - } - } } /** A helper class for updating [nav context][NAVIGATION_CONTEXT_KEY]. */ -private class NavContext(private val scopes: IScopes, private val options: SentryNavOptions) { +private class NavContext(private val scopes: INavScopes, private val options: SentryNavOptions) { private companion object { private const val BACKSTACK_KEY = "Back Stack" - private const val NAVIGATION_CONTEXT_KEY = "Navigation" } - fun update(scope: IScope, backStackEntries: List) { + fun update(scope: INavScope, backStackEntries: List) { if (backStackEntries.isEmpty()) { clear(scope) return } - scope.setContexts(NAVIGATION_CONTEXT_KEY, backStackEntries.toBackStackMap()) + scope.setNavigationContext(backStackEntries.toBackStackMap()) } - fun clear(scope: IScope) { - // We purposefully don't call IScope.removeContexts(), as it doesn't notify IScopeObserver and - // therefore doesn't write its updates to disk ¯\_ (ツ)_/¯. - scope.setContexts(NAVIGATION_CONTEXT_KEY, null as Any?) + fun clear(scope: INavScope) { + scope.setNavigationContext(null) } /** @@ -403,12 +378,11 @@ private class NavContext(private val scopes: IScopes, private val options: Sentr */ fun updateTransaction( transaction: ITransaction, - scope: IScope, + scope: INavScope, backStack: BackStackData, ) { if (scopes.options.isEnableScreenTracking) { - val appContext = - transaction.contexts.app ?: io.sentry.protocol.Contexts(scope.contexts).app ?: App() + val appContext = transaction.contexts.app ?: scope.app ?: App() appContext.viewNames = listOf(backStack.topEntryNormalized.name) transaction.contexts.setApp(appContext) @@ -432,12 +406,12 @@ private class NavScreen { private var lastScreenName: String? = null - fun update(scope: IScope, currentEntry: NormalizedSentryBackStackEntry) { + fun update(scope: INavScope, currentEntry: NormalizedSentryBackStackEntry) { scope.screen = currentEntry.name lastScreenName = currentEntry.name } - fun clear(scope: IScope) { + fun clear(scope: INavScope) { val screenName = lastScreenName ?: return if (scope.screen == screenName) { scope.screen = null @@ -447,7 +421,7 @@ private class NavScreen { } /** A helper class for generating nav breadcrumbs. */ -private class NavBreadcrumbs(private val scopes: IScopes) { +private class NavBreadcrumbs(private val scopes: INavScopes) { fun emit(fromEntry: NormalizedSentryBackStackEntry?, toEntry: NormalizedSentryBackStackEntry) { val breadcrumb = diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserver.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserver.kt new file mode 100644 index 0000000000..8cf9c6e1be --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserver.kt @@ -0,0 +1,55 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import io.sentry.compose.navigation3.lifecycle.NavScopes + +/** + * A [BackStackObserver] whose updates to a shared mutable [IScope] are coordinated with other + * `BackStackObserver`s. + * + * Coordination is achieved via [NavScopes]. + * + * **Usage** + * + * Call [attach] before the first [onBackStackChanged], and call [dispose] when observation ends. + * + * Instances targeting the same [IScope] must be created with the same [NavLeaseCoordinator]. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. Invocations should be confined to the same thread as the + * [LifecycleEventObserver] callbacks implemented by [NavScopes]. + */ +internal class CoordinatedBackStackObserver( + scopes: IScopes, + options: SentryNavOptions, + entryMapper: ForwardingBackStackEntryMapper, + coordinator: NavLeaseCoordinator, +) { + + private val navScopes = NavScopes(scopes, coordinator) + private val observer = BackStackObserver(navScopes, options, entryMapper) + + fun attach(lifecycle: Lifecycle) { + navScopes.attach(lifecycle) + } + + fun onBackStackChanged(backStack: List) { + navScopes.onBackStackUpdate() + observer.onBackStackChanged(backStack) + } + + fun dispose() { + // Call disposal on both the observer and navScopes in case their (currently overlapping) + // implementations drift over time. + observer.dispose() + navScopes.dispose() + } +} + +/** The [NavLeaseCoordinator] shared by default by all [SentryNavEffect]s. */ +internal val defaultNavLeaseCoordinator: NavLeaseCoordinator = NavLeaseCoordinator() 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..c666122efc 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,9 +4,11 @@ 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 +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator import org.jetbrains.annotations.ApiStatus /** @@ -41,8 +43,7 @@ import org.jetbrains.annotations.ApiStatus * * **Data generated** * - * By default, the following data is produced every time the top of the provided [backStack] - * changes: + * By default, the following are produced every time the top of the provided [backStack] changes: * * - a new navigation transaction (unless another transaction is already active on the current * scope) @@ -55,20 +56,23 @@ import org.jetbrains.annotations.ApiStatus * * **Limitations** * - * `SentryNavEffect` generates all Sentry data based solely on the top entry of your back stack. In - * particular, it has no awareness of + * *Tracks the back stack, not scenes*: `SentryNavEffect` generates all Sentry data based solely on + * the top entry of your back stack. In particular, it has no awareness of * [Scenes](https://developer.android.com/guide/navigation/navigation-3/scenes). Transaction routes, * breadcrumbs, and screen names are all derived from the top entry of the back stack and are * updated as it changes. * - * `SentryNavEffect` doesn't make any special accommodations for + * *No accommodation for predictive back*: `SentryNavEffect` doesn't make any special accommodations + * for * [predictive back](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture) * 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. + * *Limited support for simultaneously active `SentryNavEffect`s*: Multiple `SentryNavEffect`s are + * supported only in certain setups: roughly, one effect per Activity or Fragment, placed next to + * (and not inside) its `NavDisplay`. See the + * [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups) + * for a detailed list of supported vs unsupported configurations. * * @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( + remember(scopes, options, lifecycle, coordinator) { + CoordinatedBackStackObserver( scopes = scopes, options = options, entryMapper = ForwardingBackStackEntryMapper { currentBackStackEntryMapper.value }, + coordinator = coordinator, ) } + // Register first so the initial prepared back stack update can acquire navigation ownership. + DisposableEffect(observer) { + observer.attach(lifecycle) + onDispose { observer.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 @@ -120,10 +134,6 @@ internal fun SentryNavEffect( observer.onBackStackChanged(backStack = copy) onDispose {} } - - DisposableEffect(observer) { - onDispose { observer.cleanup() } - } } /** diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt index 0c7cdc9f53..d331afabe6 100644 --- a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt @@ -1,5 +1,8 @@ package io.sentry.compose.navigation3 +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry import com.google.common.truth.Truth.assertThat import io.sentry.Breadcrumb import io.sentry.Hint @@ -16,6 +19,9 @@ import io.sentry.SentryTracer import io.sentry.TransactionContext import io.sentry.TransactionOptions import io.sentry.compose.navigation3.ArgumentDropReason.Companion.ARGUMENT_DROP_REASON_KEY +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import io.sentry.compose.navigation3.lifecycle.NavScopes import io.sentry.protocol.App import io.sentry.protocol.TransactionNameSource import org.junit.Test @@ -90,7 +96,7 @@ class BackStackObserverTest { scope.options.isEnableScreenTracking = config.enableScreenTracking return BackStackObserver( - scopes = scopes, + scopes = leaseholderNavScopes(), options = SentryNavOptions { enableNavigationBreadcrumbs = config.enableNavigationBreadcrumbs @@ -102,6 +108,23 @@ class BackStackObserverTest { ) } + /** + * Real [NavScopes] that hold the nav lease for the whole test, so [BackStackObserver] writes + * straight through to [scope] without lease coordination getting in the way. + */ + private fun leaseholderNavScopes(): NavScopes { + val owner = + object : LifecycleOwner { + override val lifecycle = + LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + } + return NavScopes(scopes, NavLeaseCoordinator()).apply { + attach(owner.lifecycle) + // The sole resumed owner's first update acquires the lease, which it then keeps. + onBackStackUpdate() + } + } + private companion object { fun createOptions(logger: ILogger): SentryOptions = SentryOptions().apply { @@ -741,22 +764,60 @@ class BackStackObserverTest { } @Test - fun `cleanup clears observer owned tracked state`() { + fun `dispose finishes the active nav transaction and unbinds it from the scope`() { val fixture = Fixture() val sut = fixture.getSut() - sut.onBackStackChanged(listOf(HomeScreen())) val transaction = fixture.startedTransactions.single() - sut.cleanup() + sut.dispose() assertThat(transaction.isFinished).isTrue() assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `dispose clears the screen name and back stack context it recorded`() { + val fixture = Fixture() + val sut = fixture.getSut() + sut.onBackStackChanged(listOf(HomeScreen(), ProfileScreen("123"))) + + sut.dispose() + assertThat(fixture.scope.screen).isNull() assertThat(fixture.scope.contexts.app?.viewNames).isNull() assertThat(fixture.scope.contexts.containsKey(NAVIGATION_CONTEXT_KEY)).isFalse() } + @Test + fun `dispose keeps a screen name that was set after the observer recorded its own`() { + val fixture = Fixture() + val sut = fixture.getSut() + sut.onBackStackChanged(listOf(HomeScreen())) + fixture.scope.screen = "/HostScreen" + + sut.dispose() + + assertThat(fixture.scope.screen).isEqualTo("/HostScreen") + } + + @Test + fun `dispose makes the next back stack update a first navigation with no from entry`() { + val fixture = Fixture() + val sut = fixture.getSut() + val home = HomeScreen() + sut.onBackStackChanged(listOf(home)) + + sut.dispose() + sut.onBackStackChanged(listOf(home)) + + assertThat(fixture.breadcrumbs).hasSize(2) + assertThat(fixture.breadcrumbs.last().data).containsExactly("to", "/HomeScreen") + assertThat(fixture.startedTransactions).hasSize(2) + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.last()) + assertThat(fixture.scope.screen).isEqualTo("/HomeScreen") + } + private fun IScope.navigationBackStack(): List>? { val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserverTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserverTest.kt new file mode 100644 index 0000000000..08c2003fbb --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserverTest.kt @@ -0,0 +1,174 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.google.common.truth.Truth.assertThat +import io.sentry.IScopes +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class CoordinatedBackStackObserverTest { + + @Test + fun `onBackStackChanged() - acquires the nav lease before applying the first update`() { + val fixture = Fixture() + fixture.givenPausedLeaseholder() + val sut = fixture.getSut() + + sut.onBackStackChanged(listOf("Home")) + + // An update applied before the lease is acquired is staged and gets no nav transaction. + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.single()) + } + + @Test + fun `a resumed owner doesn't rebind a nav transaction it retired by navigating without the nav lease`() { + val fixture = Fixture() + val homeLifecycle = fixture.resumedLifecycle() + val homeObserver = fixture.getSut(homeLifecycle) + val pickerObserver = fixture.getSut() + homeObserver.onBackStackChanged(listOf("Home")) + val homeTransaction = fixture.startedTransaction("/Home") + // A pending child span keeps the nav transaction, which waits for its children, unfinished. + homeTransaction.startChild("http.client") + homeLifecycle.currentState = Lifecycle.State.STARTED + pickerObserver.onBackStackChanged(listOf("Picker")) + homeObserver.onBackStackChanged(listOf("Home", "Result")) + check(!homeTransaction.isFinished) + + homeLifecycle.currentState = Lifecycle.State.RESUMED + + assertThat(fixture.scope.screen).isEqualTo("/Result") + // "/Result" was reached without the nav lease, so it has no nav transaction to bind. + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `dispose() - clears the nav data it published`() { + val fixture = Fixture() + val sut = fixture.getSut() + sut.onBackStackChanged(listOf("Home")) + + sut.dispose() + + assertThat(fixture.scope.screen).isNull() + } + + @Test + fun `dispose() - without the nav lease, leaves the leaseholder's nav data and nav transaction alone`() { + val fixture = Fixture() + val homeObserver = fixture.getSut() + val settingsObserver = fixture.getSut() + homeObserver.onBackStackChanged(listOf("Home")) + // The settings observer's owner is also resumed, so it takes the nav lease from the home one. + settingsObserver.onBackStackChanged(listOf("Settings")) + val settingsTransaction = fixture.startedTransaction("/Settings") + + homeObserver.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(settingsTransaction) + assertThat(settingsTransaction.isFinished).isFalse() + } + + @Test + fun `dispose() - after its lifecycle is destroyed, leaves the leaseholder's nav data and nav transaction alone`() { + val fixture = Fixture() + val homeLifecycle = fixture.resumedLifecycle() + val homeObserver = fixture.getSut(homeLifecycle) + val settingsObserver = fixture.getSut() + homeObserver.onBackStackChanged(listOf("Home")) + homeLifecycle.currentState = Lifecycle.State.STARTED + settingsObserver.onBackStackChanged(listOf("Settings")) + val settingsTransaction = fixture.startedTransaction("/Settings") + // Destroying the lifecycle before the composition is disposed unregisters the home observer's + // owner from the coordinator ahead of its dispose() call. + homeLifecycle.currentState = Lifecycle.State.DESTROYED + + homeObserver.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(settingsTransaction) + assertThat(settingsTransaction.isFinished).isFalse() + } + + private class Fixture { + val scope = + Scope( + SentryOptions.empty().apply { + dsn = "http://key@localhost/proj" + tracesSampleRate = 1.0 + idleTimeout = null + deadlineTimeout = 0 + } + ) + val coordinator = NavLeaseCoordinator() + val startedTransactions = mutableListOf() + private val scopes = + mock().also { scopes -> + whenever(scopes.options).thenReturn(scope.options) + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + null + } + .whenever(scopes) + .configureScope(any()) + doAnswer { + val transactionContext = it.arguments[0] as TransactionContext + val transactionOptions = it.arguments[1] as TransactionOptions + SentryTracer(transactionContext, scopes, transactionOptions) + .also(startedTransactions::add) + } + .whenever(scopes) + .startTransaction(any(), any()) + } + + fun startedTransaction(name: String): SentryTracer = startedTransactions.single { + it.name == name + } + + /** Registers another owner that takes the nav lease and then pauses, keeping the lease. */ + fun givenPausedLeaseholder() { + val other = Any() + coordinator.registerCandidate(other, Lifecycle.State.RESUMED, {}, {}) + coordinator.onBackStackUpdate(other, scope) + coordinator.pauseCandidate(other, scope) + check(coordinator.hasLease(other)) + } + + fun getSut(lifecycle: Lifecycle = resumedLifecycle()): CoordinatedBackStackObserver = + CoordinatedBackStackObserver( + scopes = scopes, + options = SentryNavOptions(), + entryMapper = + ForwardingBackStackEntryMapper { + BackStackEntryMapper { SentryBackStackEntry(it) } + }, + coordinator = coordinator, + ) + .apply { attach(lifecycle) } + + fun resumedLifecycle(): LifecycleRegistry = + object : LifecycleOwner { + override val lifecycle = + LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + } + .lifecycle + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectLifecycleIntegrationTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectLifecycleIntegrationTest.kt new file mode 100644 index 0000000000..7034ba29f6 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectLifecycleIntegrationTest.kt @@ -0,0 +1,817 @@ +package io.sentry.compose.navigation3 + +import android.app.Activity +import android.app.Application +import android.content.ComponentName +import android.content.Context +import android.os.Bundle +import android.os.Looper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.common.truth.Truth.assertThat +import io.sentry.IScope +import io.sentry.ISentryClient +import io.sentry.Scope +import io.sentry.ScopeType +import io.sentry.Scopes +import io.sentry.SentryOptions +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config + +/** + * Drives real Activities and Fragments through Robolectric so that each `SentryNavEffect` observes + * the lifecycle events, and the composition timing, that Android produces when screens hand off to + * one another. Lets us test the multi-[SentryNavEffect] policy implemented by our + * `io.sentry.compose.navigation3.lifecycle` package. + * + * Activity transitions are driven through [ActivityController]s in the order the framework uses + * (e.g., the outgoing Activity pauses before the incoming one is created, and stops after the + * incoming one resumes and becomes visible). Fragment transitions are left entirely to the real + * FragmentManager (and, for ViewPager2 pages, to FragmentStateAdapter). Compositions run when a + * ComposeView attaches to its window, as on a device. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class SentryNavEffectLifecycleIntegrationTest { + + @After + fun tearDown() { + Fixture.current = null + } + + // Activity handoffs + @Test + fun `an incoming activity publishes its own nav data and transaction over the outgoing activity's`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + home.pause() + Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_PAUSE", "Checkout:ON_RESUME", "Checkout:composed(RESUMED)") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(homeTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Checkout") + .inOrder() + } + + @Test + fun `an incoming activity's nav data and transaction survive the outgoing activity stopping`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + home.pause() + Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + + home.stop() + fixture.idle() + + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction).isSameInstanceAs(checkoutTransaction) + assertThat(checkoutTransaction.isFinished).isFalse() + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + } + + @Test + fun `an incoming activity's nav data and transaction survive the outgoing activity being destroyed`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + val propagationContext = fixture.scope.propagationContext + + home.stop() + home.destroy() + fixture.idle() + + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction).isSameInstanceAs(checkoutTransaction) + assertThat(checkoutTransaction.isFinished).isFalse() + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + assertThat(fixture.scope.propagationContext).isSameInstanceAs(propagationContext) + } + + @Test + fun `going back to a stopped activity restores its nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + val checkout = Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + home.stop() + fixture.idle() + + checkout.pause() + home.restart() + home.resume() + fixture.idle() + checkout.stop() + checkout.destroy() + fixture.idle() + + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(checkoutTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + @Test + fun `closing an activity that only paused the one beneath it restores the one beneath's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + val checkout = Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + + checkout.pause() + home.resume() + fixture.idle() + checkout.stop() + checkout.destroy() + fixture.idle() + + assertThat(fixture.events).doesNotContain("Home:ON_STOP") + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(checkoutTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + } + + // Activity recreation + @Test + fun `a recreated activity publishes its own nav data and transaction over the destroyed instance's`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val destroyedTransaction = requireNotNull(fixture.scope.transaction) + fixture.events.clear() + + home.recreate() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_DESTROY", "Home:ON_CREATE", "Home:composed(RESUMED)") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(destroyedTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + } + + @Test + fun `recreating an activity finishes the destroyed instance's transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val destroyedTransaction = requireNotNull(fixture.scope.transaction) + + home.recreate() + fixture.idle() + + assertThat(destroyedTransaction.isFinished).isTrue() + } + + // A single Activity going to the background and back + @Test + fun `a backgrounded activity's nav data and transaction stay on the scope while the activity is stopped`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + home.pause() + home.stop() + fixture.idle() + + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + } + + @Test + fun `navigating after an activity returns to the foreground publishes the destination's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + home.stop() + fixture.idle() + home.restart() + home.resume() + fixture.idle() + + fixture.backStack("Home") += "Profile" + fixture.idle() + + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Profile", "/Home").inOrder() + } + + @Test + fun `navigating before a restarted activity resumes publishes the destination's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + home.stop() + fixture.idle() + home.restart() + fixture.idle() + + fixture.backStack("Home") += "Profile" + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_PAUSE", "Home:ON_STOP", "Home:ON_START") + .inOrder() + assertThat(fixture.events.last()).isEqualTo("Home:ON_START") + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Profile", "/Home").inOrder() + } + + @Test + fun `destroying an activity that navigated before resuming finishes its transaction and clears its nav data`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + home.pause() + home.stop() + fixture.idle() + home.restart() + fixture.idle() + fixture.backStack("Home") += "Profile" + fixture.idle() + val profileTransaction = requireNotNull(fixture.scope.transaction) + + home.stop() + home.destroy() + fixture.idle() + + assertThat(fixture.events.takeLast(2)) + .containsExactly("Home:ON_STOP", "Home:ON_DESTROY") + .inOrder() + assertThat(profileTransaction.name).isEqualTo("/Profile") + assertThat(profileTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.navigationEntries()).isNull() + } + + // Fragment replacement + @Test + fun `replacing a fragment publishes the incoming fragment's nav data after the outgoing view is destroyed`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + host + .get() + .supportFragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, CheckoutFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:view:ON_STOP", "Checkout:composed(CREATED)", "Home:view:ON_DESTROY") + .inOrder() + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Checkout") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + } + + @Test + fun `popping a replaced fragment restores the first fragment's nav data`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val fragmentManager = host.get().supportFragmentManager + fragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, CheckoutFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + fixture.events.clear() + + fragmentManager.popBackStack() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast( + "Checkout:view:ON_STOP", + "Home:composed(CREATED)", + "Checkout:view:ON_DESTROY", + ) + .inOrder() + assertThat(checkoutTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + } + + // ViewPager2 pages + @Test + fun `a preloaded view pager page does not displace the current page's nav data and transaction`() { + val fixture = Fixture.create() + + Robolectric.buildActivity(ViewPagerActivity::class.java).setup() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast( + "Home:view:ON_RESUME", + "Checkout:composed(CREATED)", + "Checkout:view:ON_START", + ) + .inOrder() + assertThat(fixture.events).doesNotContain("Checkout:view:ON_RESUME") + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + } + + @Test + fun `switching to a preloaded view pager page publishes the incoming page's nav data`() { + val fixture = Fixture.create() + val activity = Robolectric.buildActivity(ViewPagerActivity::class.java).setup() + fixture.idle() + fixture.events.clear() + + activity.get().viewPager.setCurrentItem(1, false) + fixture.idle() + + assertThat(fixture.events) + .containsExactly("Home:view:ON_PAUSE", "Checkout:view:ON_RESUME") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + } + + @Test + fun `switching to a preloaded view pager page publishes a new propagation context instead of a nav transaction`() { + val fixture = Fixture.create() + val activity = Robolectric.buildActivity(ViewPagerActivity::class.java).setup() + fixture.idle() + val propagationContext = fixture.scope.propagationContext + + activity.get().viewPager.setCurrentItem(1, false) + fixture.idle() + + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(propagationContext) + } + + // Dialogs + @Test + fun `a dialog fragment shown over a fragment publishes its own nav data while the fragment stays resumed`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + PickerDialogFragment().show(host.get().supportFragmentManager, "picker") + fixture.idle() + + assertThat(fixture.events).contains("Picker:composed(RESUMED)") + assertThat(fixture.events).doesNotContain("Home:view:ON_PAUSE") + assertThat(fixture.scope.screen).isEqualTo("/Picker") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Picker") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(homeTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Picker") + } + + @Test + fun `dismissing a dialog fragment restores the still-resumed fragment's nav data and transaction`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + val dialog = PickerDialogFragment() + dialog.show(host.get().supportFragmentManager, "picker") + fixture.idle() + val pickerTransaction = requireNotNull(fixture.scope.transaction) + + dialog.dismiss() + fixture.idle() + + assertThat(fixture.events).contains("Picker:view:ON_DESTROY") + assertThat(fixture.events.filter { it == "Home:view:ON_RESUME" }).hasSize(1) + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(pickerTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + @Test + fun `a dialog fragment shown over an activity publishes its own nav data while the activity stays resumed`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + PickerDialogFragment().show(home.get().supportFragmentManager, "picker") + fixture.idle() + + assertThat(fixture.events).contains("Picker:composed(RESUMED)") + assertThat(fixture.events).doesNotContain("Home:ON_PAUSE") + assertThat(fixture.scope.screen).isEqualTo("/Picker") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Picker") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(homeTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Picker") + } + + @Test + fun `dismissing a dialog fragment restores the still-resumed activity's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + val dialog = PickerDialogFragment() + dialog.show(home.get().supportFragmentManager, "picker") + fixture.idle() + val pickerTransaction = requireNotNull(fixture.scope.transaction) + + dialog.dismiss() + fixture.idle() + + assertThat(fixture.events).contains("Picker:view:ON_DESTROY") + assertThat(fixture.events.filter { it == "Home:ON_RESUME" }).hasSize(1) + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(pickerTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + // Screens without an effect + + @Test + fun `an activity without an effect keeps the outgoing activity's nav data and transaction on the scope`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + home.pause() + Robolectric.buildActivity(PlainActivity::class.java).setup() + fixture.idle() + home.stop() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_PAUSE", "Plain:ON_RESUME", "Home:ON_STOP") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + @Test + fun `navigating after closing an activity without an effect publishes the destination's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + val plain = Robolectric.buildActivity(PlainActivity::class.java).setup() + fixture.idle() + home.stop() + fixture.idle() + plain.pause() + home.restart() + home.resume() + fixture.idle() + plain.stop() + plain.destroy() + fixture.idle() + + fixture.backStack("Home") += "Profile" + fixture.idle() + + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.isFinished).isFalse() + assertThat(fixture.scope.navigationEntries()).containsExactly("/Profile", "/Home").inOrder() + } + + @Test + fun `replacing a fragment with one without an effect clears the outgoing fragment's nav data and transaction`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + host + .get() + .supportFragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, PlainFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:view:ON_STOP", "Plain:view:ON_CREATE", "Home:view:ON_DESTROY") + .inOrder() + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.navigationEntries()).isNull() + } + + @Test + fun `popping a fragment without an effect republishes the replaced fragment's nav data with a new transaction`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val destroyedTransaction = requireNotNull(fixture.scope.transaction) + val fragmentManager = host.get().supportFragmentManager + fragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, PlainFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + fixture.events.clear() + + fragmentManager.popBackStack() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Plain:view:ON_STOP", "Home:composed(CREATED)", "Plain:view:ON_DESTROY") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(destroyedTransaction) + assertThat(fixture.scope.transaction?.isFinished).isFalse() + } + + private fun IScope.navigationEntries(): List? { + val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null + val backStack = navigationContext["Back Stack"] as? List<*> ?: return null + return backStack.map { (it as? Map<*, *>)?.get("entry") } + } + + internal open class NavEffectActivity(private val route: String) : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val fixture = Fixture.require() + lifecycle.addObserver(fixture.eventRecorder(route)) + setContentView(navEffectView(this, route)) + } + } + + internal class HomeActivity : NavEffectActivity("Home") + + internal class CheckoutActivity : NavEffectActivity("Checkout") + + internal class PlainActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(Fixture.require().eventRecorder("Plain")) + setContentView(FrameLayout(this)) + } + } + + internal class FragmentHostActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(FrameLayout(this).apply { id = CONTAINER_ID }) + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction().add(CONTAINER_ID, HomeFragment()).commit() + } + } + + companion object { + val CONTAINER_ID: Int = View.generateViewId() + } + } + + internal class ViewPagerActivity : FragmentActivity() { + + lateinit var viewPager: ViewPager2 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(Fixture.require().eventRecorder("ViewPager")) + viewPager = + ViewPager2(this).apply { + // Preload the page next to the current one. FragmentStateAdapter caps a preloaded page + // at STARTED and resumes only the current page. + offscreenPageLimit = 1 + adapter = PagesAdapter(this@ViewPagerActivity) + } + setContentView(viewPager) + } + + private class PagesAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { + + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment = + if (position == 0) HomeFragment() else CheckoutFragment() + } + } + + internal open class NavEffectFragment(private val route: String) : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = navEffectView(requireContext(), route) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycle.addObserver(Fixture.require().eventRecorder("$route:view")) + } + } + + internal class HomeFragment : NavEffectFragment("Home") + + internal class CheckoutFragment : NavEffectFragment("Checkout") + + internal class PlainFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = FrameLayout(requireContext()) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycle.addObserver(Fixture.require().eventRecorder("Plain:view")) + } + } + + internal class PickerDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = navEffectView(requireContext(), "Picker") + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycle.addObserver(Fixture.require().eventRecorder("Picker:view")) + } + } + + private class Fixture private constructor() { + + val options = + SentryOptions().apply { + dsn = "http://key@localhost/1" + defaultScopeType = ScopeType.CURRENT + setTracesSampleRate(1.0) + isEnableScreenTracking = true + idleTimeout = null + deadlineTimeout = 0 + } + + val scope = Scope(options) + val scopes = Scopes(scope, Scope(options), Scope(options), "navigation lifecycle test") + val coordinator = NavLeaseCoordinator() + val navOptions = SentryNavOptions() + val mapper = BackStackEntryMapper { SentryBackStackEntry(it) } + + /** Lifecycle events and first compositions, in the order they were observed. */ + val events = mutableListOf() + + private val backStacks = mutableMapOf>() + + init { + val client = mock() + whenever(client.isEnabled).thenReturn(true) + scopes.bindClient(client) + } + + fun idle() { + shadowOf(Looper.getMainLooper()).idle() + } + + /** + * The back stack observed by the effect of each Activity or Fragment showing [route], which + * starts out holding just `route`. + */ + fun backStack(route: String): SnapshotStateList = + backStacks.getOrPut(route) { mutableStateListOf(route) } + + fun eventRecorder(name: String) = LifecycleEventObserver { _, event -> + events += "$name:$event" + } + + /** + * The host-app setup under test: one `SentryNavEffect` in an Activity's or Fragment's content. + */ + @Composable + fun NavEffect(route: String) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(Unit) { + events += "$route:composed(${lifecycle.currentState})" + onDispose {} + } + SentryNavEffect(backStack(route), mapper, navOptions, scopes, coordinator) + } + + companion object { + var current: Fixture? = null + + fun require(): Fixture = checkNotNull(current) { "Fixture.create() was not called" } + + fun create(): Fixture { + val application: Application = ApplicationProvider.getApplicationContext() + val packageManager = shadowOf(application.packageManager) + listOf( + HomeActivity::class.java, + CheckoutActivity::class.java, + PlainActivity::class.java, + FragmentHostActivity::class.java, + ViewPagerActivity::class.java, + ) + .forEach { activityClass: Class -> + packageManager.addActivityIfNotPresent( + ComponentName(application.packageName, activityClass.name) + ) + } + return Fixture().also { current = it } + } + } + } + + private companion object { + + fun navEffectView(context: Context, route: String): View { + val fixture = Fixture.require() + return ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { fixture.NavEffect(route) } + } + } + } +} 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 acc8445b57..32536d28cc 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,16 @@ 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.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +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 @@ -15,12 +20,18 @@ import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.IScope import io.sentry.IScopes +import io.sentry.ISentryClient +import io.sentry.ITransaction +import io.sentry.PropagationContext import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType +import io.sentry.Scopes import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.TransactionContext import io.sentry.TransactionOptions +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator import org.junit.Rule import org.junit.Test import org.junit.rules.TestWatcher @@ -35,6 +46,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config(sdk = [30]) +@Suppress("LargeClass") class SentryNavEffectTest { private val defaultEntryMapper = @@ -72,6 +84,7 @@ class SentryNavEffectTest { } val scope = Scope(options) val scopes = mock() + val coordinator = NavLeaseCoordinator() val breadcrumbs = mutableListOf() val transactions = mutableListOf() @@ -111,6 +124,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } @@ -134,6 +148,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -161,6 +176,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -188,6 +204,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -221,6 +238,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -254,6 +272,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions { maxCapturedBackStackEntries = 0 }, scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -277,6 +296,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -313,6 +333,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) val currentTop = backStack.last() @@ -347,6 +368,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -384,6 +406,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -416,6 +439,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -458,6 +482,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -495,6 +520,7 @@ class SentryNavEffectTest { options = options.value, backStackEntryMapper = defaultEntryMapper, scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -524,7 +550,9 @@ class SentryNavEffectTest { SentryNavEffect( backStack = backStack, backStackEntryMapper = defaultEntryMapper, + options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } } @@ -542,6 +570,230 @@ 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 `effect records navigation while its lifecycle owner is paused`() { + val fixture = NavTestFixture() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + val backStack = mutableStateListOf("Home") + val options = SentryNavOptions() + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner) { + SentryNavEffect( + backStack = backStack, + backStackEntryMapper = BackStackEntryMapper { SentryBackStackEntry(it) }, + options = options, + scopes = fixture.scopes, + coordinator = fixture.coordinator, + ) + } + } + composeRule.runOnIdle { + owner.pause() + backStack += "Profile" + } + composeRule.runOnIdle { + 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() + } + } + + @Test + fun `navigating without the lease records breadcrumbs, then publishes with a fresh trace on resume`() { + val fixture = NavTestFixture() + val ownerA = NavTestLifecycle(Lifecycle.State.RESUMED) + val ownerB = NavTestLifecycle() + val backStackA = mutableStateListOf("Home") + val showB = mutableStateOf(false) + val options = SentryNavOptions() + val mapper = BackStackEntryMapper { SentryBackStackEntry(it) } + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides ownerA) { + SentryNavEffect(backStackA, mapper, options, fixture.scopes, fixture.coordinator) + } + if (showB.value) { + CompositionLocalProvider(LocalLifecycleOwner provides ownerB) { + SentryNavEffect(listOf("Picker"), mapper, options, fixture.scopes, fixture.coordinator) + } + } + } + + // A starts B: A pauses, B is created and takes over, B resumes, then A stops. + composeRule.runOnIdle { + ownerA.pause() + showB.value = true + } + composeRule.runOnIdle { + ownerB.resume() + ownerA.stop() + } + // B finishes with a result: B pauses, then A restarts and navigates before it resumes. + composeRule.runOnIdle { + ownerB.pause() + ownerA.start() + backStackA += "Result" + } + lateinit var contextBeforeResume: PropagationContext + composeRule.runOnIdle { + assertThat(fixture.scope.transaction?.name).isEqualTo("/Picker") + assertThat(fixture.scope.screen).isEqualTo("/Picker") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Picker", "/Result") + .inOrder() + contextBeforeResume = fixture.scope.propagationContext + ownerA.resume() + } + + // A gets no nav transaction for /Result: one started without the lease would have collected no + // spans of its own. It publishes its screen and gets the fresh trace a transaction would have. + composeRule.runOnIdle { + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isEqualTo("/Result") + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(contextBeforeResume) + } + } + + @Test + fun `backgrounding with a translucent owner on top keeps the top owner's data published`() { + val fixture = NavTestFixture() + val beneath = NavTestLifecycle(Lifecycle.State.RESUMED) + val translucent = NavTestLifecycle() + val showTranslucent = mutableStateOf(false) + val options = SentryNavOptions() + val mapper = BackStackEntryMapper { SentryBackStackEntry(it) } + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides beneath) { + SentryNavEffect(listOf("Products"), mapper, options, fixture.scopes, fixture.coordinator) + } + if (showTranslucent.value) { + CompositionLocalProvider(LocalLifecycleOwner provides translucent) { + SentryNavEffect(listOf("Checkout"), mapper, options, fixture.scopes, fixture.coordinator) + } + } + } + + // A translucent owner opens on top: the owner beneath pauses but stays visible. + composeRule.runOnIdle { + beneath.pause() + showTranslucent.value = true + } + lateinit var checkoutTransaction: ITransaction + composeRule.runOnIdle { + translucent.resume() + checkoutTransaction = requireNotNull(fixture.scope.transaction) + assertThat(checkoutTransaction.name).isEqualTo("/Checkout") + } + + // The app goes to the background: the top owner stops before the one beneath it. + composeRule.runOnIdle { + translucent.pause() + translucent.stop() + beneath.stop() + + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.navigationBackStack()?.map { it["entry"] }) + .containsExactly("/Checkout") + assertThat(fixture.scope.transaction).isSameInstanceAs(checkoutTransaction) + } + } + + @Test + fun `regaining the lease keeps a host screen name when screen tracking is off`() { + val fixture = NavTestFixture() + fixture.options.isEnableScreenTracking = false + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + 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 { + fixture.scope.screen = "HostScreen" + + // E.g., the app goes to the background and comes back. + owner.pause() + owner.stop() + owner.start() + owner.resume() + + assertThat(fixture.scope.screen).isEqualTo("HostScreen") + assertThat(fixture.scope.navigationBackStack()?.map { it["entry"] }).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 @@ -554,3 +806,52 @@ class SentryNavEffectTest { const val BACKSTACK_KEY = "Back Stack" } } + +private 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 start() { + lifecycle.currentState = Lifecycle.State.STARTED + } + + fun stop() { + lifecycle.currentState = Lifecycle.State.CREATED + } +} + +/** Real SDK state with only the event delivery boundary stubbed. No timers or global SDK state. */ +private 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) + } +}