diff --git a/CHANGELOG.md b/CHANGELOG.md index 15817dd70d3..93a3178f762 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` ([#6229](https://github.com/getsentry/sentry-java/pull/6229)) + - 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 - Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 43c3a7660c4..865426d447a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -107,11 +107,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.3.5" } androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidxLifecycle" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version = "2.8.3" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } 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 bdfec781fc4..95bbbeb006b 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 da7ad9f36f4..922894d61c1 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -62,10 +62,15 @@ dependencies { implementation(projects.sentry) compileOnly(libs.androidx.compose.runtime) + // Provides androidx.lifecycle.compose.LocalLifecycleOwner. Hosts get it at runtime through + // Compose UI 1.7+ and navigation3-ui, both of which depend on it. + 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.inline) 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 f567f7c8c5e..6100c706202 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 37f694350fa..8e2806686e5 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 00000000000..8cf9c6e1be3 --- /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 8b6269752b6..c666122efc7 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/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt new file mode 100644 index 00000000000..53dcd04bf6f --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt @@ -0,0 +1,416 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import io.sentry.IScope +import io.sentry.ITransaction +import io.sentry.SpanStatus + +/** + * Manages a lease in order to determine which [NavScopes] instance can bind nav transactions and + * publish nav data to a shared [IScope]. + * + * `NavScopes` instances register as [candidates][Candidate] for lease ownership via + * [registerCandidate]. + * + * Note: "Owner" below refers to the `LifecycleOwner` whose lifecycle events the candidate's + * `NavScopes` relays. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. All `NavScopes` instances using this coordinator must + * confine their access to the same thread. + */ +@Suppress("TooManyFunctions") +internal class NavLeaseCoordinator { + + private val candidates = mutableListOf() + + /** Candidate responsible for the most recently published nav data in the shared scope. */ + private var publishedDataCandidate: Candidate? = null + + private var leaseHolder: Candidate? = null + + /** + * Counter that orders candidates by when this coordinator most recently observed them in resumed + * state. + * + * Each resume, including registration of an already-resumed owner, assigns the next value to that + * candidate's [Candidate.resumeOrder]. This lets fallback selection prefer the most recently + * resumed eligible candidate without relying on clock timestamps. Zero is reserved for candidates + * that have not been observed resumed and therefore cannot be chosen as fallbacks. + */ + private var resumeOrder: Long = 0 + + /** + * Registers [owner] as a candidate for the nav lease. No-ops if `owner` is already registered or + * [lifecycleState] is [destroyed][Lifecycle.State.DESTROYED]. + * + * [onPublishData] publishes `owner`'s staged nav data to the given scope when `owner` acquires + * the lease. [onClearPublishedData] clears `owner`'s published nav data from the given scope when + * its data is the most recently published and either another owner acquires the lease or `owner` + * is unregistered. + */ + fun registerCandidate( + owner: Any, + lifecycleState: Lifecycle.State, + onPublishData: (IScope) -> Unit, + onClearPublishedData: (IScope) -> Unit, + ) { + if (findCandidate(owner) != null) { + return + } + + if (lifecycleState == Lifecycle.State.DESTROYED) { + return + } + + val initialResumeOrder = if (lifecycleState == Lifecycle.State.RESUMED) ++resumeOrder else 0 + candidates += + Candidate(owner, onPublishData, onClearPublishedData, lifecycleState, initialResumeOrder) + } + + /** Records that [owner] started. */ + fun startCandidate(owner: Any) { + findCandidate(owner)?.start() + } + + /** + * Records that [owner] resumed. + * + * Restores the lease to `owner` if it already applied a back stack update and [scope] is + * non-null. + * + * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the + * candidate remains eligible for fallback once a scope becomes available. + */ + fun resumeCandidate(owner: Any, scope: IScope?) { + findCandidate(owner)?.let { + it.resume(++resumeOrder) + + if (scope != null && it.hasAppliedInitialBackStackUpdate) { + transferLeaseTo(it, scope) + } + } + } + + /** + * Records that [owner] paused. + * + * Hands the lease to another owner only if that owner is resumed (e.g., a Fragment whose dialog + * is being dismissed); otherwise keeps it. A paused leaseholder may still be the topmost visible + * owner (e.g., beneath a translucent window or in picture-in-picture mode), and any other paused + * owner is beneath it. A displaced owner gets the lease back when it resumes. + * + * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the + * candidate remains eligible for fallback once a scope becomes available. + */ + fun pauseCandidate(owner: Any, scope: IScope?) { + findCandidate(owner)?.let { + it.pause() + + if (leaseHolder === it) { + mostRecentlyResumedCandidate(excluding = it, resumedOnly = true)?.let { next -> + handOffLease(next, scope) + } + } + } + } + + /** + * Records that [owner] stopped. + * + * As with pausing, hands the lease to another owner only if that owner is resumed; otherwise the + * stopped owner keeps it. Keeping it lets a lone owner go on recording navigation that happens + * while it isn't resumed (e.g., while the app is in the background, or after it restarts but + * before it resumes), including starting nav transactions. + * + * A paused owner doesn't get the lease: when the leaseholder stops before it, the app is + * typically going to the background (e.g., a translucent leaseholder stops before the owner + * beneath it), and that owner isn't what the user last saw. A paused owner that comes back to the + * foreground gets the lease when it resumes. + * + * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the + * candidate remains eligible for fallback once a scope becomes available. + */ + fun stopCandidate(owner: Any, scope: IScope?) { + findCandidate(owner)?.let { + it.stop() + + if (leaseHolder === it) { + mostRecentlyResumedCandidate(resumedOnly = true)?.let { next -> handOffLease(next, scope) } + } + } + } + + /** + * Stops tracking [owner] (e.g., because it was destroyed or its effect left the composition). + * No-ops if `owner` isn't registered. + * + * Finishes and unbinds `owner`'s nav transaction, and clears its published nav data if that data + * is the most recently published. If `owner` holds the lease, hands it to the eligible candidate + * most recently observed resumed, even if that candidate is now only started or paused, or + * releases it if there's none. + * + * A `null` scope (e.g., the SDK is disabled) still removes the candidate, but its transaction is + * left to time out, its published data is left in place, and the lease is released rather than + * handed off. + */ + fun unregisterCandidate(owner: Any, scope: IScope?) { + findCandidate(owner)?.let { + finishTransaction(it, scope) + candidates.remove(it) + + if (publishedDataCandidate === it) { + publishedDataCandidate = null + if (scope != null) { + it.onClearPublishedData(scope) + } + } + + if (leaseHolder === it) { + handOffLease(mostRecentlyResumedCandidate(), scope) + } + } + } + + fun hasLease(owner: Any): Boolean = leaseHolder?.owner === owner + + /** + * Records that the registered [owner] is about to apply a back-stack update and acquires the nav + * lease if the owner's lifecycle permits it. + * + * An owner that isn't resumed doesn't take the lease from a resumed leaseholder, as it isn't the + * one the user is interacting with (e.g., a preloaded ViewPager2 page next to the current one). + * Instead, it acquires the lease when it resumes. + * + * If the lease transfers, ownership and transaction binding are updated before the candidate's + * staged nav data is published. Publication completes before this method returns, so the caller + * can then apply the back-stack update. Later calls for the same [owner] do nothing. + */ + fun onBackStackUpdate(owner: Any, scope: IScope) { + val candidate = findCandidate(owner) ?: return + if (candidate.hasAppliedInitialBackStackUpdate) { + return + } + + candidate.hasAppliedInitialBackStackUpdate = true + + val holder = leaseHolder + val wouldDisplaceResumedHolder = + holder != null && + holder.state == Lifecycle.State.RESUMED && + candidate.state != Lifecycle.State.RESUMED + + if (candidate.canAcquireLeaseOnFirstUpdate && !wouldDisplaceResumedHolder) { + transferLeaseTo(candidate, scope) + } + } + + /** Whether [transaction] is a nav transaction retained for any registered owner. */ + fun isNavTransaction(transaction: ITransaction): Boolean = candidates.any { + it.transaction === transaction + } + + /** + * Records [transaction] as the nav transaction of [owner], which must hold the lease. Ignored + * otherwise, as only the leaseholder starts nav transactions. + * + * If [owner] loses the lease, the transaction is unbound but retained: it's rebound if [owner] + * regains the lease while it's still unfinished and the scope is free, and finished when [owner] + * is [unregistered][unregisterCandidate]. It's forgotten once [owner] retires it; see + * [releaseTransaction]. + */ + fun retainTransaction(owner: Any, transaction: ITransaction) { + val candidate = leaseHolder ?: return + if (candidate.owner !== owner) { + return + } + candidate.transaction = transaction + } + + /** + * Forgets [transaction] as the nav transaction of [owner], if it's the one retained. Called when + * [owner] retires the transaction (e.g., it navigated away), so it's never rebound later. + * + * Retiring is distinct from losing the lease: a transaction that's still waiting for child spans + * isn't [finished][ITransaction.isFinished] yet, so without this, regaining the lease would + * rebind a transaction whose destination [owner] has already left. + */ + fun releaseTransaction(owner: Any, transaction: ITransaction) { + val candidate = findCandidate(owner) ?: return + if (candidate.transaction === transaction) { + candidate.transaction = null + } + } + + private fun findCandidate(owner: Any): Candidate? = candidates.firstOrNull { it.owner === owner } + + /** + * The eligible candidate most recently observed resumed, other than [excluding]. With + * [resumedOnly], only candidates that are resumed right now are considered; otherwise, started + * and paused ones are too. + */ + private fun mostRecentlyResumedCandidate( + excluding: Candidate? = null, + resumedOnly: Boolean = false, + ): Candidate? = + candidates + .filter { + it !== excluding && + it.hasAppliedInitialBackStackUpdate && + it.state.isAtLeast(Lifecycle.State.STARTED) && + it.resumeOrder > 0 && + (!resumedOnly || it.state == Lifecycle.State.RESUMED) + } + .maxByOrNull { it.resumeOrder } + + /** + * Hands the current lease to [next]. Without a [scope], the lease is released instead, as [next] + * could not publish its staged data. + */ + private fun handOffLease(next: Candidate?, scope: IScope?) { + if (scope != null) { + transferLeaseTo(next, scope) + } else { + leaseHolder = null + } + } + + private fun transferLeaseTo(candidate: Candidate?, scope: IScope) { + if (leaseHolder === candidate) { + return + } + + if (candidate != null) { + // Unbind another owner's transaction without finishing it, so it can be restored if that + // owner returns. If this owner's retained transaction has since finished, discard it + // instead of binding a finished transaction to the shared scope. + scope.withTransaction { current -> + if (current != null && candidates.any { it !== candidate && it.transaction === current }) { + scope.clearTransaction() + } + } + + val transaction = candidate.transaction + if (transaction?.isFinished == true) { + clearTransactionFromScope(candidate, scope) + candidate.transaction = null + } else if (transaction != null) { + // The navigation lease doesn't give this owner control over an unrelated transaction + // already bound to the shared scope. Restore the owner's retained nav transaction only if + // the scope is free or already holds that same transaction. + scope.withTransaction { current -> + if (current == null || current === transaction) { + scope.transaction = transaction + } + } + } + } + + leaseHolder = candidate + if (candidate != null) { + // Clear the previous publisher's nav data before publishing the candidate's, so the + // previous owner's values don't linger where the candidate has nothing staged to replace + // them. + val previousPublisher = publishedDataCandidate + if (previousPublisher != null && previousPublisher !== candidate) { + previousPublisher.onClearPublishedData(scope) + } + publishedDataCandidate = candidate + candidate.onPublishData(scope) + } + } + + /** Finishes and unbinds [candidate]'s nav transaction, then forgets it. */ + private fun finishTransaction(candidate: Candidate, scope: IScope?) { + // No need to finish the transaction if we don't have a scope (e.g., the SDK is disabled), as + // the transaction can't be sent and it will time out anyway. Not finishing it has the added + // advantage of keeping SDK work inside `configureScope`'s error handling. + if (scope != null) { + val transaction = candidate.transaction + if (transaction != null && !transaction.isFinished) { + transaction.finish(transaction.status ?: SpanStatus.OK) + } + clearTransactionFromScope(candidate, scope) + } + + candidate.transaction = null + } + + private fun clearTransactionFromScope(candidate: Candidate, scope: IScope) { + val transaction = candidate.transaction ?: return + scope.withTransaction { current -> + if (current === transaction) { + scope.clearTransaction() + } + } + } + + /** A [LifecycleOwner] that has registered itself as a candidate for lease ownership. */ + private class Candidate( + val owner: Any, + val onPublishData: (IScope) -> Unit, + val onClearPublishedData: (IScope) -> Unit, + initialState: Lifecycle.State, + initialResumeOrder: Long, + ) { + + /** The candidate's lifecycle state, as last observed. */ + var state: Lifecycle.State = initialState + private set + + /** + * Whether the candidate has been observed STOPPED since + * [registration][NavLeaseCoordinator.registerCandidate]. + */ + private var wasStopped: Boolean = false + + var transaction: ITransaction? = null + + /** + * See [NavLeaseCoordinator.resumeOrder]. Non-zero once the candidate has been observed RESUMED + * since [registration][NavLeaseCoordinator.registerCandidate]. + */ + var resumeOrder: Long = initialResumeOrder + private set + + var hasAppliedInitialBackStackUpdate: Boolean = false + + /** + * Whether the candidate's lifecycle permits its first back stack update since + * [registration][NavLeaseCoordinator.registerCandidate] to acquire the nav lease, including + * taking it from another owner that isn't resumed: it's resumed, or it has neither resumed nor + * stopped since registration (i.e., it's still coming up for the first time). A paused or + * restarted candidate is returning to the screen rather than arriving on it. + * + * This checks the candidate's own lifecycle only; it does not indicate whether the candidate + * has already applied its first update, nor account for a resumed leaseholder (see + * [NavLeaseCoordinator.onBackStackUpdate]). + */ + val canAcquireLeaseOnFirstUpdate: Boolean + get() = state == Lifecycle.State.RESUMED || (resumeOrder == 0L && !wasStopped) + + fun start() { + // Attaching to an already-resumed lifecycle replays ON_START before ON_RESUME, so only move + // up from CREATED. + if (!state.isAtLeast(Lifecycle.State.STARTED)) { + state = Lifecycle.State.STARTED + } + } + + fun resume(order: Long) { + resumeOrder = order + state = Lifecycle.State.RESUMED + } + + fun pause() { + state = Lifecycle.State.STARTED + } + + fun stop() { + state = Lifecycle.State.CREATED + wasStopped = true + } + } +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt new file mode 100644 index 00000000000..fb75354fc4c --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt @@ -0,0 +1,140 @@ +package io.sentry.compose.navigation3.lifecycle + +import io.sentry.IScope +import io.sentry.ITransaction +import io.sentry.PropagationContext +import io.sentry.protocol.App +import io.sentry.protocol.Contexts + +/** + * An [INavScope] implementation that writes to and reads from [delegate] only when [owner] holds + * the nav lease; otherwise it falls back to [stagedNavData]. + */ +internal class NavScope( + private val delegate: IScope, + private val stagedNavData: StagedNavData, + private val coordinator: NavLeaseCoordinator, + private val owner: Any, +) : INavScope { + + private val hasLease: Boolean + get() = coordinator.hasLease(owner) + + override var screen: String? + get() = if (hasLease) delegate.screen else stagedNavData.screen + set(value) { + stagedNavData.stageScreen(value) + if (hasLease) { + stagedNavData.publishScreen(delegate) + } + } + + override fun setNavigationContext(value: Any?) { + stagedNavData.stageNavigationContext(value) + if (hasLease) { + stagedNavData.publishNavigationContext(delegate) + } + } + + override val app: App? + get() = Contexts(delegate.contexts).app + + /** + * The transaction bound to the scope. Without the lease, another owner's nav transaction isn't + * reported, so [owner]'s attempt to start a nav transaction reaches [NavScopes.startTransaction], + * which declines it and defers the propagation context rotation it implies. Non-nav transactions + * (e.g., the host's) are always reported, so they still block nav transactions as usual. + */ + override val transaction: ITransaction? + get() = + delegate.transaction?.takeUnless { current -> + !hasLease && coordinator.isNavTransaction(current) + } + + override fun bindTransaction(transaction: ITransaction) { + if (!hasLease) { + return + } + delegate.withTransaction { current -> + if (current == null) { + delegate.transaction = transaction + } + } + } + + /** + * Unbinds [transaction] if it's bound, and, with or without the lease, tells the [coordinator] to + * stop retaining it, so a retired transaction isn't restored when [owner] regains the lease. + */ + override fun unbindTransaction(transaction: ITransaction) { + delegate.withTransaction { current -> + if (current === transaction) { + delegate.clearTransaction() + } + } + coordinator.releaseTransaction(owner, transaction) + } + + override fun clearFinishedTransaction() { + if (!hasLease) { + return + } + delegate.withTransaction { current -> + if (current?.isFinished == true) { + delegate.clearTransaction() + } + } + } + + override fun rotatePropagationContext() { + if (hasLease) { + delegate.withPropagationContext { delegate.propagationContext = PropagationContext() } + } else { + stagedNavData.deferPropagationContextRotation() + } + } +} + +/** + * The subset of the [IScope] interface used to record navigation data. + * + * Protects against accidental misuse of what would otherwise be a partially-implemented `IScope` + * interface. New operations against the underlying `IScope` instance must be added here. Operations + * not directly delegable to `IScope` should ***not*** be added. + */ +internal interface INavScope { + + var screen: String? + + /** A copy of the scope's app context, if any. */ + val app: App? + + /** + * The transaction bound to the scope, if any, as far as starting a nav transaction is concerned: + * see [NavScope.transaction]. + */ + val transaction: ITransaction? + + /** Binds [transaction] to the scope if no transaction is bound to it. */ + fun bindTransaction(transaction: ITransaction) + + /** + * Unbinds [transaction] from the scope if it's the bound transaction, and ensures it won't be + * bound to the scope again. + */ + fun unbindTransaction(transaction: ITransaction) + + /** Unbinds the scope's transaction if it has finished. */ + fun clearFinishedTransaction() + + /** + * Sets or, given `null`, clears the value stored under [NAVIGATION_CONTEXT_KEY]. + * + * Implementations should clear via [IScope.setContexts] rather than [IScope.removeContexts], as + * the latter doesn't notify scope observers and therefore doesn't persist the removal to disk. + */ + fun setNavigationContext(value: Any?) + + /** Replaces the scope's propagation context with a new one. */ + fun rotatePropagationContext() +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt new file mode 100644 index 00000000000..07dad727940 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt @@ -0,0 +1,374 @@ +package io.sentry.compose.navigation3.lifecycle + +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.SentryLevel +import io.sentry.SentryOptions +import io.sentry.TransactionContext +import io.sentry.TransactionOptions + +/** The key under which nav context (i.e., the captured back stack) is stored in a scope. */ +internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation" + +/** + * An [INavScopes] implementation that lets us coordinate updates to navigation state across + * multiple `SentryNavEffect`s. (Also supports the trivial scenario where only a single + * `SentryNavEffect` exists.) + * + * Needed because all `SentryNavEffect`s mutate a single [IScope] instance by default and will + * clobber or interleave one another's updates unless managed properly: + * ``` + * LifecycleOwner (e.g., Activity or Fragment) + * ↓ owns + * SentryNavEffect + * ↓ + * CoordinatedBackStackObserver (wrapper) + * ├── BackStackObserver — computes nav data and nav transactions + * └── NavScopes — controls if nav data + txns reach the real scope + * ↓ + * NavLeaseCoordinator — arbitrates ownership across all observers + * ↓ + * shared IScope + * ``` + * + * **Limitations** + * + * Multi-effect support is restricted to the scenarios documented in the + * [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups). + * + * This class implements the policy that underlies those scenarios. It assumes that, at any moment, + * the user is interacting with at most one registered [LifecycleOwner] in the host app, and that + * the `LifecycleOwner` being interacted with is the one that was most recently resumed. + * + * Concretely, a host app can safely use more than one `SentryNavEffect` only if: + * + * 1. each `SentryNavEffect` is called in the same composable as the `NavDisplay` it observes; + * 2. no `SentryNavEffect` is called from inside a `NavDisplay`, e.g., from a nav entry, scene, or + * entry decorator†; + * 3. each Activity or Fragment calls at most one `SentryNavEffect` from its own content (Fragments + * count separately from the Activity that hosts them); and + * 4. the user can interact with only one of those Activities or Fragments at a time. + * + * † Meant to ensure each effect's `LocalLifecycleOwner` is an Activity or a Fragment and not a + * `NavDisplay`-provided owner. `NavDisplay` provides its own `LocalLifecycleOwner` to the entries + * and scenes it renders. That owner drops to STARTED state during transitions and while overlays + * are shown. An effect called inside a `NavDisplay` would therefore (wrongly) gain and lose the nav + * lease this class manages as navigation animates, rather than as its Activity or Fragment comes + * and goes. + * + * **A fundamental tradeoff** + * + * During `LifecycleOwner` overlap, the nav transaction and scoped nav data can reflect + * **_either_**: + * + * 1. the outgoing owner that's still visible, or + * 2. the incoming owner that may crash before it becomes visible, + * + * but not both. This class opts for (2) so that the incoming destination populates crash context + * and any work performed is associated with it† – the idea being that start-up logic is more likely + * to cause a crash or produce interesting spans than an outgoing destination's steady state. + * + * † 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 publish nav state or start nav transactions** + * + * This class works by only allowing `NavScopes` instances holding a lease to start nav transactions + * and publish their navigation data to the shared `IScope`. + * + * `NavScopes` instances without a lease write to their own copy of [stagedNavData], 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 shared `IScope`. + * + * **Lease handoff policy** + * + * The lease is acquired preemptively by an incoming owner's `NavScopes` instance when it applies + * its first [back stack update][onBackStackUpdate].† + * + * A displaced owner remains eligible for lease restoration until it [stops][ON_STOP]. It gets the + * lease back when it [resumes][ON_RESUME], or sooner if the new owner leaves or pauses while the + * displaced owner is still visible. If restored, the displaced owner's nav data and unfinished + * transaction are reinstated. + * + * † There are some exceptions, e.g., the leaseholder is resumed and the incoming owner isn't (e.g., + * a preloaded ViewPager2 Fragment page next to the current one). Such an owner acquires the lease + * when it resumes instead. + * + * 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: A (no other resumed owner) + * │ A remains eligible for restoration + * │ + * │ attach() + * │ │ + * │ Navigation lease: A ◄────────────────────┤ + * │ Scope transaction: txA │ + * │ Published data: A │ + * │ B registered, awaiting initial back │ + * │ stack update │ + * │ │ + * │ Initial back stack update + * │ │ + * │ Suspend & unbind txA ◄───────────────────┤ + * │ Navigation lease: B │ + * │ Published data: B │ + * │ Scope transaction: txB │ + * │ (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: B (no other resumed ◄─┤ + * owner) │ + * │ + * ON_STOP + * │ + * Navigation lease: B (no other resumed ◄─┤ + * owner); B keeps recording navigation │ + * B no longer eligible for restoration │ + * │ + * ON_DESTROY + * │ + * Finish txB if unfinished; ◄──────────────┤ + * unbind if still bound │ + * Clear B's remaining screen/back stack │ + * Retain recorded breadcrumbs │ + * Navigation lease: none │ + * ▼ + * + * 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 │ + * ``` + * + * **Usage** + * + * 1. Create a `NavScopes` instance per `SentryNavEffect`. + * + * 2. Pass the same [NavLeaseCoordinator] instance to all `NavScopes` 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. Call [onBackStackUpdate] before applying each back stack update. + * + * 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 NavScopes( + private val delegate: IScopes, + private val coordinator: NavLeaseCoordinator, +) : INavScopes, LifecycleEventObserver { + + override val options: SentryOptions + get() = delegate.options + + private val owner: Any = Any() + private var attachedLifecycle: Lifecycle? = null + private val stagedNavData = StagedNavData() + + /** + * Registers this [NavScopes] instance with [lifecycle]. No-ops if `lifecycle` has already been + * attached or is [destroyed][Lifecycle.Event.ON_DESTROY]. + * + * Attaching to a second, different lifecycle is unsupported; it's logged and ignored. + */ + fun attach(lifecycle: Lifecycle) { + val currentLifecycle = attachedLifecycle + if (currentLifecycle != null && currentLifecycle !== lifecycle) { + delegate.options.logger.log( + SentryLevel.WARNING, + "NavScopes is already attached to a different lifecycle; ignoring the new lifecycle.", + ) + return + } + + if (currentLifecycle === lifecycle) { + return + } + if (lifecycle.currentState == Lifecycle.State.DESTROYED) { + return + } + + attachedLifecycle = lifecycle + + coordinator.registerCandidate( + owner = owner, + lifecycleState = lifecycle.currentState, + onPublishData = stagedNavData::publishTo, + onClearPublishedData = stagedNavData::clearPublishedStateFrom, + ) + + lifecycle.addObserver(this) + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + ON_START -> coordinator.startCandidate(owner) + ON_RESUME -> withScopeOrNull { coordinator.resumeCandidate(owner, it) } + ON_PAUSE -> withScopeOrNull { coordinator.pauseCandidate(owner, it) } + ON_STOP -> withScopeOrNull { coordinator.stopCandidate(owner, it) } + ON_DESTROY -> dispose() + else -> Unit + } + } + + /** + * Announces that a back stack update is about to be applied, so this instance can acquire the nav + * lease if it's eligible to. + * + * Must be called before each update. If [configureScope][IScopes.configureScope] skips its + * callback (e.g., the SDK is disabled), the update itself can't be applied either, so nothing is + * recorded and the next update applied is treated as this instance's first. + */ + fun onBackStackUpdate() { + delegate.configureScope { scope -> coordinator.onBackStackUpdate(owner, scope) } + } + + override fun configureScope(callback: (INavScope) -> Unit) { + delegate.configureScope { scope -> + callback(NavScope(scope, stagedNavData, coordinator, owner)) + } + } + + /** + * Starts a nav transaction, provided this instance holds the nav lease. + * + * Without the lease, returns a no-op transaction and defers the propagation context rotation a + * new transaction would have implied until the lease is acquired. A transaction started without + * the lease couldn't be bound to the scope, so its destination's spans would attach to the + * leaseholder's bound transaction instead, and the empty transaction would be dropped. + * + * The [coordinator] retains the leaseholder's transaction, so it can be restored if this instance + * loses the lease and regains it while the transaction is still unfinished. + */ + override fun startTransaction( + transactionContext: TransactionContext, + transactionOptions: TransactionOptions, + ): ITransaction { + if (!coordinator.hasLease(owner)) { + stagedNavData.deferPropagationContextRotation() + return NoOpTransaction.getInstance() + } + + return delegate.startTransaction(transactionContext, transactionOptions).also { transaction -> + if (!transaction.isNoOp) { + coordinator.retainTransaction(owner, transaction) + } + } + } + + /** Records [breadcrumb] immediately; breadcrumbs only append, so they aren't lease-protected. */ + override fun addBreadcrumb(breadcrumb: Breadcrumb, hint: Hint) { + delegate.addBreadcrumb(breadcrumb, hint) + } + + /** + * Releases the lease, clears remaining published state, and finishes this instance's transaction. + * + * Also stops observing the attached [LifecycleOwner] and cleans up memory references. + */ + fun dispose() { + attachedLifecycle?.removeObserver(this) + attachedLifecycle = null + + // Clear nav state, unbind the nav transaction, and avoid leaking the LifecycleOwner. + withScopeOrNull { coordinator.unregisterCandidate(owner, it) } + } + + /** + * Runs [block] with the real scope, or with `null` if [configureScope][IScopes.configureScope] + * skips its callback (e.g., the SDK is disabled), so coordinator bookkeeping still happens. + * + * A callback that ran but threw is not retried, as retrying could let its exception escape into + * the host app. + */ + private fun withScopeOrNull(block: (IScope?) -> Unit) { + var callbackInvoked = false + delegate.configureScope { scope -> + callbackInvoked = true + block(scope) + } + + if (!callbackInvoked) { + block(null) + } + } +} + +/** + * The subset of the [IScopes] interface used to record navigation data. + * + * Protects against accidental misuse of what would otherwise be a partially-implemented `IScopes` + * interface. New operations against the underlying `IScopes` instance must be added here. + * Operations not directly delegable to `IScopes` should ***not*** be added. + */ +internal interface INavScopes { + + val options: SentryOptions + + /** Runs [callback] with the current [INavScope], if the SDK provides a scope. */ + fun configureScope(callback: (INavScope) -> Unit) + + /** + * Starts a transaction, or returns a no-op transaction if none can be started (e.g., tracing is + * disabled, or the implementation declines to start one; see [NavScopes.startTransaction]). + */ + fun startTransaction( + transactionContext: TransactionContext, + transactionOptions: TransactionOptions, + ): ITransaction + + fun addBreadcrumb(breadcrumb: Breadcrumb, hint: Hint) +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md new file mode 100644 index 00000000000..7d697812ecf --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md @@ -0,0 +1,12 @@ +# `io.sentry.compose.navigation3.lifecycle` + +Package contains code for coordinating multiple `SentryNavEffect`s and their access to a shared `IScope`, so that overlapping effects (e.g., +during Activity or Fragment transitions) don't clobber one another's screen names, nav context, or nav transactions. + +See the `NavScopes` KDoc for the lease policy that decides which effect may write to the scope. + +## Encapsulation policy + +This package is meant to encapsulate knowledge about the possibility of multiple `SentryNavEffect`s and the shared mutable state issues it +raises. Addressing those concerns is complicated. Confining them here makes them easier to reason about and easier to swap for alternative +logic should we someday need to. diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt new file mode 100644 index 00000000000..964e3694ed6 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt @@ -0,0 +1,106 @@ +package io.sentry.compose.navigation3.lifecycle + +import io.sentry.IScope +import io.sentry.PropagationContext + +/** + * Holds navigation state staged for publication: a screen name and back stack nav context. + * + * Only state that occupies a single slot in the shared [IScope] is staged. Nav events (i.e., + * breadcrumbs) only append to the scope, so they can't overwrite another instance's data and are + * recorded immediately, irrespective of lease ownership. + * + * Propagation context isn't staged either, since an earlier propagation context mustn't overwrite + * the scope's current one. Instead, a rotation requested without the lease, or implied by a nav + * transaction that couldn't be started without it, is [deferred][deferPropagationContextRotation] + * and performed once when the lease is acquired. As a result, any destination reached in the + * meantime still gets a fresh trace. + */ +internal class StagedNavData { + + /** The staged screen name, published or not. */ + var screen: String? = null + private set + + private var navigationContext: Any? = null + + private var hasNavigationContextUpdate = false + private var hasScreenUpdate = false + + /** Whether a propagation context rotation was requested while this instance lacked the lease. */ + private var hasDeferredPropagationContextRotation = false + + // These may differ from the staged values after background updates. We keep the exact published + // references so cleanup preserves host replacements, including structurally equal replacements. + private var publishedNavigationContext: Any? = null + private var publishedScreen: String? = null + + /** Stages [screen] for publication, replacing any previously staged screen. */ + fun stageScreen(screen: String?) { + this.screen = screen + hasScreenUpdate = true + } + + /** Stages [value] as the nav context for publication, replacing any previously staged value. */ + fun stageNavigationContext(value: Any?) { + navigationContext = value + hasNavigationContextUpdate = true + } + + /** Requests a propagation context rotation for when this instance next [publishes][publishTo]. */ + fun deferPropagationContextRotation() { + hasDeferredPropagationContextRotation = true + } + + /** + * Publishes the staged screen and nav context, and performs any deferred propagation context + * rotation. + * + * Only staged values are published, so values the host app or other integrations set (e.g., a + * screen name while screen tracking is off) survive. Values published by the previous leaseholder + * are cleared separately, via its [clearPublishedStateFrom]. + */ + fun publishTo(realScope: IScope) { + if (hasScreenUpdate) { + publishScreen(realScope) + } + if (hasNavigationContextUpdate) { + publishNavigationContext(realScope) + } + if (hasDeferredPropagationContextRotation) { + realScope.withPropagationContext { realScope.propagationContext = PropagationContext() } + hasDeferredPropagationContextRotation = false + } + } + + /** Publishes the staged screen to [realScope]. */ + fun publishScreen(realScope: IScope) { + publishedScreen = screen + realScope.screen = screen + } + + /** Publishes the staged nav context to [realScope]. */ + fun publishNavigationContext(realScope: IScope) { + publishedNavigationContext = navigationContext + realScope.setContexts(NAVIGATION_CONTEXT_KEY, navigationContext) + } + + /** + * Clears the screen and nav context this instance published, unless something else has replaced + * them in [realScope] since. + */ + fun clearPublishedStateFrom(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 + } +} 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 0c7cdc9f534..d331afabe69 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 00000000000..08c2003fbbf --- /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 00000000000..7034ba29f68 --- /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 7b8a4b99467..eb43428815f 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,13 +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 @@ -36,6 +46,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config(sdk = [30]) +@Suppress("LargeClass") class SentryNavEffectTest { private val defaultEntryMapper = @@ -73,6 +84,7 @@ class SentryNavEffectTest { } val scope = Scope(options) val scopes = mock() + val coordinator = NavLeaseCoordinator() val breadcrumbs = mutableListOf() val transactions = mutableListOf() @@ -112,6 +124,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } @@ -135,6 +148,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -162,6 +176,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -189,6 +204,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -222,6 +238,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -255,6 +272,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions { maxCapturedBackStackEntries = 0 }, scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -278,6 +296,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -314,6 +333,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) val currentTop = backStack.last() @@ -348,6 +368,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -385,6 +406,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -417,6 +439,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -459,6 +482,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -496,6 +520,7 @@ class SentryNavEffectTest { options = options.value, backStackEntryMapper = defaultEntryMapper, scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -525,7 +550,9 @@ class SentryNavEffectTest { SentryNavEffect( backStack = backStack, backStackEntryMapper = defaultEntryMapper, + options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } } @@ -543,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 @@ -562,3 +813,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) + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt new file mode 100644 index 00000000000..6b3af236ac0 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt @@ -0,0 +1,800 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.SentryOptions +import io.sentry.SpanStatus +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class NavLeaseCoordinatorTest { + + private val owner = Any() + private val incoming = Any() + private val incumbent = Any() + private val previous = Any() + + private val transaction = mock() + private val unrelatedTransaction = mock() + + @Test + fun `a registered created owner acquires the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `registering the same owner twice preserves its first publication callback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.registerCandidate( + owner, + Lifecycle.State.RESUMED, + { fixture.published += "duplicate" }, + { fixture.cleared += "duplicate" }, + ) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `registering a destroyed owner does not make it eligible for the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + + fixture.register(owner, Lifecycle.State.DESTROYED) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.published).isEmpty() + } + + @Test + fun `equal but distinct owners do not share the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val registeredOwner = String(charArrayOf('a')) + val equalOwner = String(charArrayOf('a')) + fixture.acquireLease(registeredOwner) + + val result = navLeaseCoordinator.hasLease(equalOwner) + + assertThat(result).isFalse() + assertThat(navLeaseCoordinator.hasLease(registeredOwner)).isTrue() + } + + @Test + fun `starting a created owner leaves it eligible to acquire the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.startCandidate(owner) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `restarting a stopped owner does not take the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + fixture.acquireLease(incumbent) + + navLeaseCoordinator.startCandidate(owner) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() + assertThat(fixture.published).containsExactly(incumbent) + } + + @Test + fun `resuming an owner before its first back stack update leaves the nav lease unassigned`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.published).isEmpty() + } + + @Test + fun `resuming a stopped owner after its first back stack update transfers the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + } + + @Test + fun `a paused owner cannot acquire the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.RESUMED) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.published).isEmpty() + } + + @Test + fun `an owner that isn't resumed doesn't take the nav lease from a resumed leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + + fixture.acquireLease(owner, state = Lifecycle.State.STARTED) + + assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() + assertThat(fixture.published).containsExactly(incumbent) + } + + @Test + fun `an owner kept from the nav lease by a resumed leaseholder acquires it when it resumes`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + fixture.acquireLease(owner, state = Lifecycle.State.STARTED) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + } + + @Test + fun `an owner that isn't resumed takes the nav lease from a paused leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + navLeaseCoordinator.pauseCandidate(incumbent, fixture.scope) + + fixture.acquireLease(owner, state = Lifecycle.State.CREATED) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `an owner that isn't resumed takes the nav lease from a stopped leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + navLeaseCoordinator.pauseCandidate(incumbent, fixture.scope) + navLeaseCoordinator.stopCandidate(incumbent, fixture.scope) + + fixture.acquireLease(owner, state = Lifecycle.State.CREATED) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + } + + @Test + fun `a resumed owner takes the nav lease from a resumed leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + + fixture.acquireLease(owner) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `acquiring the nav lease clears the previous owner's published nav data before publishing`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val calls = mutableListOf() + navLeaseCoordinator.registerCandidate( + previous, + Lifecycle.State.RESUMED, + onPublishData = { calls += "publish previous" }, + onClearPublishedData = { calls += "clear previous" }, + ) + navLeaseCoordinator.onBackStackUpdate(previous, fixture.scope) + navLeaseCoordinator.registerCandidate( + incoming, + Lifecycle.State.RESUMED, + onPublishData = { calls += "publish incoming" }, + onClearPublishedData = { calls += "clear incoming" }, + ) + + navLeaseCoordinator.onBackStackUpdate(incoming, fixture.scope) + + assertThat(calls) + .containsExactly("publish previous", "clear previous", "publish incoming") + .inOrder() + } + + @Test + fun `regaining the nav lease doesn't clear the owner's own published nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, null) + fixture.cleared.clear() + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(previous, owner, owner).inOrder() + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `releasing the nav lease leaves the owner's published nav data in place`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + fixture.cleared.clear() + + navLeaseCoordinator.pauseCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `later back stack updates do not republish nav data or reclaim the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val first = Any() + val second = Any() + fixture.acquireLease(first) + fixture.acquireLease(second, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.onBackStackUpdate(first, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(second)).isTrue() + assertThat(fixture.published).containsExactly(first, second).inOrder() + } + + @Test + fun `pausing the leaseholder hands the nav lease to the most recently resumed owner that's still resumed`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val older = Any() + val recent = Any() + fixture.acquireLease(older) + fixture.acquireLease(recent) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(recent)).isTrue() + assertThat(fixture.published.last()).isSameInstanceAs(recent) + } + + @Test + fun `pausing the leaseholder keeps the nav lease when other eligible owners are only paused`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + fixture.acquireLease(incoming) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(incoming)).isTrue() + assertThat(fixture.published).containsExactly(owner, incoming).inOrder() + } + + @Test + fun `a displaced paused owner regains the nav lease when it resumes`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + fixture.acquireLease(incoming) + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner, incoming, owner).inOrder() + } + + @Test + fun `pausing the leaseholder skips a resumed owner without a back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val eligible = Any() + val uninitialized = Any() + fixture.acquireLease(eligible) + fixture.register(uninitialized, Lifecycle.State.RESUMED) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(eligible)).isTrue() + assertThat(navLeaseCoordinator.hasLease(uninitialized)).isFalse() + } + + @Test + fun `pausing the only eligible owner keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `pausing the only eligible owner without a scope keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + + navLeaseCoordinator.pauseCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `resuming a paused leaseholder does not republish its nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `stopping the leaseholder restores a resumed owner`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.published.last()).isSameInstanceAs(previous) + } + + @Test + fun `stopping the only eligible owner keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `stopping the only eligible owner without a scope keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, null) + + navLeaseCoordinator.stopCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `stopping the leaseholder doesn't hand the nav lease to a paused owner`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + assertThat(fixture.cleared).containsExactly(previous) + } + + @Test + fun `a paused owner passed over when the leaseholder stopped gets the nav lease when it resumes`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(previous, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.published).containsExactly(previous, owner, previous).inOrder() + } + + @Test + fun `restarting a stopped leaseholder keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + navLeaseCoordinator.startCandidate(owner) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `stopping an owner excludes it from later nav lease fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + navLeaseCoordinator.stopCandidate(previous, fixture.scope) + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + } + + @Test + fun `resuming without a scope keeps the owner eligible for nav lease fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.resumeCandidate(owner, null) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + fixture.acquireLease(incoming, state = Lifecycle.State.CREATED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `resuming without a scope does not transfer the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() + assertThat(fixture.published).containsExactly(incumbent) + } + + @Test + fun `pausing the leaseholder without a scope releases the nav lease without publishing a fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + } + + @Test + fun `stopping the leaseholder without a scope releases the nav lease without publishing a fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.stopCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + } + + @Test + fun `an unregistered owner's nav transaction is not retained`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val other = Any() + fixture.acquireLease(owner) + + navLeaseCoordinator.retainTransaction(other, transaction) + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verifyNoInteractions(transaction) + } + + @Test + fun `a nav transaction recorded without the lease is ignored`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.retainTransaction(owner, transaction) + navLeaseCoordinator.unregisterCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + assertThat(fixture.scope.transaction).isNull() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verifyNoInteractions(transaction) + } + + @Test + fun `an unfinished nav transaction is restored when its owner regains the lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + assertThat(fixture.scope.transaction).isNull() + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + verify(transaction, never()).finish(any()) + } + + @Test + fun `restoring a nav transaction preserves an unrelated scope transaction`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + fixture.scope.transaction = unrelatedTransaction + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(unrelatedTransaction) + } + + @Test + fun `a finished nav transaction is discarded when its owner regains the lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + whenever(transaction.isFinished).thenReturn(true) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(fixture.scope.transaction).isNull() + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `a released unfinished nav transaction is not restored when its owner regains the lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.releaseTransaction(owner, transaction) + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `releasing a nav transaction other than the retained one keeps the retained one restorable`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.releaseTransaction(owner, unrelatedTransaction) + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + } + + @Test + fun `a displaced owner's retained nav transaction remains identifiable until unregistering`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isTrue() + assertThat(navLeaseCoordinator.isNavTransaction(unrelatedTransaction)).isFalse() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `acquiring the nav lease binds its retained transaction before publishing nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val transactionAtPublication = mutableListOf() + navLeaseCoordinator.registerCandidate( + owner, + Lifecycle.State.RESUMED, + onPublishData = { transactionAtPublication += it.transaction }, + onClearPublishedData = {}, + ) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + navLeaseCoordinator.retainTransaction(owner, transaction) + fixture.scope.transaction = transaction + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(transactionAtPublication).containsExactly(null, transaction).inOrder() + } + + @Test + fun `unregistering the leaseholder finishes its transaction and clears its published nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verify(transaction).finish(SpanStatus.OK) + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.cleared).containsExactly(owner) + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + } + + @Test + fun `unregistering a former leaseholder preserves the current owner's published nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + fixture.cleared.clear() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `unregistering the leaseholder hands the nav lease to the most recently resumed eligible owner`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val older = Any() + fixture.acquireLease(older) + navLeaseCoordinator.pauseCandidate(older, fixture.scope) + fixture.acquireLease(previous) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + fixture.acquireLease(owner) + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.published.last()).isSameInstanceAs(previous) + assertThat(fixture.cleared).containsExactly(older, previous, owner).inOrder() + } + + @Test + fun `unregistering the leaseholder without a scope releases the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + fixture.cleared.clear() + + navLeaseCoordinator.unregisterCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `unregistering the leaseholder without a scope doesn't finish its nav transaction`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + + navLeaseCoordinator.unregisterCandidate(owner, null) + + verify(transaction, never()).finish(any()) + } + + @Test + fun `unregistering a former leaseholder finishes its retained transaction without clearing an unrelated one`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + whenever(transaction.status).thenReturn(SpanStatus.CANCELLED) + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + fixture.scope.transaction = unrelatedTransaction + fixture.cleared.clear() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verify(transaction).finish(SpanStatus.CANCELLED) + assertThat(fixture.scope.transaction).isSameInstanceAs(unrelatedTransaction) + assertThat(fixture.cleared).isEmpty() + } + + private class Fixture { + val navLeaseCoordinator = NavLeaseCoordinator() + val scope = Scope(SentryOptions()) + val published = mutableListOf() + val cleared = mutableListOf() + + fun register(owner: Any, state: Lifecycle.State) { + navLeaseCoordinator.registerCandidate( + owner, + state, + onPublishData = { published += owner }, + onClearPublishedData = { cleared += owner }, + ) + } + + fun acquireLease( + owner: Any, + state: Lifecycle.State = Lifecycle.State.RESUMED, + transaction: ITransaction? = null, + ) { + register(owner, state) + navLeaseCoordinator.onBackStackUpdate(owner, scope) + if (transaction != null) { + navLeaseCoordinator.retainTransaction(owner, transaction) + scope.transaction = transaction + } + } + + companion object { + fun create(): Fixture = Fixture() + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt new file mode 100644 index 00000000000..8c0bb76a261 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt @@ -0,0 +1,294 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.SentryOptions +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class NavScopeTest { + + @Test + fun `getScreen() - reads the real screen with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + navScope.screen = "Staged" + fixture.realScope.screen = "Host" + + val screen = navScope.screen + + assertThat(screen).isEqualTo("Host") + } + + @Test + fun `getScreen() - reads the staged screen without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + fixture.realScope.screen = "Host" + fixture.stagedNavData.stageScreen("Staged") + + val screen = navScope.screen + + assertThat(screen).isEqualTo("Staged") + } + + @Test + fun `setScreen() - publishes the screen with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + + navScope.screen = "Profile" + + assertThat(fixture.realScope.screen).isEqualTo("Profile") + } + + @Test + fun `setScreen() - stages the screen without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + fixture.realScope.screen = "Host" + + navScope.screen = "Profile" + + assertThat(fixture.realScope.screen).isEqualTo("Host") + assertThat(navScope.screen).isEqualTo("Profile") + } + + @Test + fun `setNavigationContext() - publishes the navigation context with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + val navigationContext = mapOf("route" to "Profile") + + navScope.setNavigationContext(navigationContext) + + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]) + .isSameInstanceAs(navigationContext) + } + + @Test + fun `setNavigationContext() - stages the navigation context without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + val hostContext = mapOf("route" to "Host") + val navigationContext = mapOf("route" to "Profile") + fixture.realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + navScope.setNavigationContext(navigationContext) + + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + fixture.stagedNavData.publishTo(fixture.realScope) + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]) + .isSameInstanceAs(navigationContext) + } + + @Test + fun `rotatePropagationContext() - replaces the real context with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val realContext = fixture.realScope.propagationContext + + fixture.navScope.rotatePropagationContext() + + assertThat(fixture.realScope.propagationContext).isNotSameInstanceAs(realContext) + } + + @Test + fun `rotatePropagationContext() - defers rotating the real context without the nav lease`() { + val fixture = Fixture.create() + val realContext = fixture.realScope.propagationContext + + fixture.navScope.rotatePropagationContext() + + assertThat(fixture.realScope.propagationContext).isSameInstanceAs(realContext) + fixture.stagedNavData.publishTo(fixture.realScope) + assertThat(fixture.realScope.propagationContext).isNotSameInstanceAs(realContext) + } + + @Test + fun `bindTransaction() - binds to a free scope with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + + fixture.navScope.bindTransaction(transaction) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(transaction) + } + + @Test + fun `bindTransaction() - preserves an already bound transaction`() { + val fixture = Fixture.create(ownsLease = true) + val bound = mock() + fixture.realScope.transaction = bound + + fixture.navScope.bindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(bound) + } + + @Test + fun `bindTransaction() - does nothing without the nav lease`() { + val fixture = Fixture.create() + + fixture.navScope.bindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `unbindTransaction() - unbinds the given transaction without the nav lease`() { + val fixture = Fixture.create() + val transaction = mock() + fixture.realScope.transaction = transaction + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `unbindTransaction() - releases the owner's retained nav transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + fixture.retainTransaction(transaction) + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `unbindTransaction() - releases the owner's retained nav transaction without the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + fixture.retainTransaction(transaction) + fixture.giveLeaseToAnotherOwner() + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `unbindTransaction() - preserves a different bound transaction`() { + val fixture = Fixture.create(ownsLease = true) + val bound = mock() + fixture.realScope.transaction = bound + + fixture.navScope.unbindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(bound) + } + + @Test + fun `clearFinishedTransaction() - unbinds a finished transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + fixture.realScope.transaction = + mock().also { whenever(it.isFinished).thenReturn(true) } + + fixture.navScope.clearFinishedTransaction() + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `clearFinishedTransaction() - does nothing without the nav lease`() { + val fixture = Fixture.create() + val finished = mock().also { whenever(it.isFinished).thenReturn(true) } + fixture.realScope.transaction = finished + + fixture.navScope.clearFinishedTransaction() + + assertThat(fixture.realScope.transaction).isSameInstanceAs(finished) + } + + @Test + fun `transaction - hides another owner's nav transaction without the nav lease`() { + val fixture = Fixture.create() + val other = Any() + val otherTransaction = mock() + fixture.coordinator.registerCandidate(other, Lifecycle.State.RESUMED, {}, {}) + fixture.coordinator.onBackStackUpdate(other, fixture.realScope) + fixture.coordinator.retainTransaction(other, otherTransaction) + fixture.realScope.transaction = otherTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isNull() + } + + @Test + fun `transaction - reports a non-nav transaction without the nav lease`() { + val fixture = Fixture.create() + val hostTransaction = mock() + fixture.realScope.transaction = hostTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `transaction - reports the bound transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val boundTransaction = mock() + fixture.realScope.transaction = boundTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isSameInstanceAs(boundTransaction) + } + + private class Fixture + private constructor( + val realScope: Scope, + val stagedNavData: StagedNavData, + val coordinator: NavLeaseCoordinator, + val navScope: NavScope, + private val owner: Any, + ) { + + /** Retains [transaction] as the owner's nav transaction; the owner must hold the nav lease. */ + fun retainTransaction(transaction: ITransaction) { + coordinator.retainTransaction(owner, transaction) + check(coordinator.isNavTransaction(transaction)) + } + + /** Lets another resumed owner take the nav lease from the owner. */ + fun giveLeaseToAnotherOwner() { + val other = Any() + coordinator.registerCandidate(other, Lifecycle.State.RESUMED, {}, {}) + coordinator.onBackStackUpdate(other, realScope) + check(!coordinator.hasLease(owner)) + } + + companion object { + + fun create(ownsLease: Boolean = false): Fixture { + val realScope = Scope(SentryOptions.empty()) + val stagedNavData = StagedNavData() + val coordinator = NavLeaseCoordinator() + val owner = Any() + coordinator.registerCandidate( + owner, + Lifecycle.State.RESUMED, + stagedNavData::publishTo, + stagedNavData::clearPublishedStateFrom, + ) + if (ownsLease) { + coordinator.onBackStackUpdate(owner, realScope) + } + + return Fixture( + realScope, + stagedNavData, + coordinator, + NavScope(realScope, stagedNavData, coordinator, owner), + owner, + ) + } + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt new file mode 100644 index 00000000000..4470072b354 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt @@ -0,0 +1,208 @@ +package io.sentry.compose.navigation3.lifecycle + +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 +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class NavScopesTest { + + @Test + fun `attach() - ignores a second, different lifecycle`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + val first = TestLifecycleOwner() + val second = TestLifecycleOwner() + navScopes.attach(first.lifecycle) + + navScopes.attach(second.lifecycle) + + assertThat(first.lifecycle.observerCount).isEqualTo(1) + assertThat(second.lifecycle.observerCount).isEqualTo(0) + } + + @Test + fun `lifecycle callbacks are recorded while scope callbacks are skipped`() { + val fixture = Fixture() + val outgoing = fixture.navScopes() + val outgoingOwner = TestLifecycleOwner() + outgoing.attach(outgoingOwner.lifecycle) + fixture.isEnabled = false + outgoingOwner.lifecycle.currentState = Lifecycle.State.RESUMED + fixture.isEnabled = true + outgoing.applyFirstBackStackUpdate() + + val incoming = fixture.navScopes() + val incomingOwner = TestLifecycleOwner(Lifecycle.State.RESUMED) + incoming.attach(incomingOwner.lifecycle) + incoming.applyFirstBackStackUpdate() + assertThat(fixture.hasLease(incoming)).isTrue() + + incoming.dispose() + + assertThat(fixture.hasLease(outgoing)).isTrue() + } + + @Test + fun `onBackStackUpdate() - an update skipped by the scope does not count as the first update`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + val owner = TestLifecycleOwner() + navScopes.attach(owner.lifecycle) + fixture.isEnabled = false + navScopes.applyFirstBackStackUpdate() + fixture.isEnabled = true + + navScopes.applyFirstBackStackUpdate() + + assertThat(fixture.hasLease(navScopes)).isTrue() + } + + @Test + fun `addBreadcrumb() - records breadcrumbs immediately without the nav lease`() { + val fixture = Fixture() + val leaseholder = fixture.navScopes() + leaseholder.attach(TestLifecycleOwner().lifecycle) + leaseholder.applyFirstBackStackUpdate() + val other = fixture.navScopes() + other.attach(TestLifecycleOwner().lifecycle) + val breadcrumb = Breadcrumb() + val hint = Hint() + + other.addBreadcrumb(breadcrumb, hint) + + assertThat(fixture.hasLease(other)).isFalse() + verify(fixture.scopes).addBreadcrumb(breadcrumb, hint) + } + + @Test + fun `startTransaction() - returns a no-op transaction without the nav lease`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + + val transaction = + covered.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + + assertThat(fixture.hasLease(covered)).isFalse() + assertThat(transaction.isNoOp).isTrue() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `startTransaction() - declining without the nav lease defers a propagation context rotation`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + val incomingContext = fixture.scope.propagationContext + + covered.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + assertThat(fixture.scope.propagationContext).isSameInstanceAs(incomingContext) + + incoming.dispose() + + assertThat(fixture.hasLease(covered)).isTrue() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(incomingContext) + } + + @Test + fun `startTransaction() - returns a no-op transaction after dispose`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + navScopes.attach(TestLifecycleOwner().lifecycle) + navScopes.applyFirstBackStackUpdate() + navScopes.dispose() + + val transaction = + navScopes.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + + assertThat(transaction.isNoOp).isTrue() + } + + @Test + fun `rotatePropagationContext() - a rotation requested without the nav lease happens once the lease is acquired`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + val incomingContext = fixture.scope.propagationContext + + covered.configureScope { it.rotatePropagationContext() } + assertThat(fixture.scope.propagationContext).isSameInstanceAs(incomingContext) + + incoming.dispose() + + assertThat(fixture.hasLease(covered)).isTrue() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(incomingContext) + } + + private fun NavScopes.applyFirstBackStackUpdate() { + onBackStackUpdate() + } + + private class TestLifecycleOwner(initialState: Lifecycle.State = Lifecycle.State.CREATED) : + LifecycleOwner { + override val lifecycle: LifecycleRegistry = + LifecycleRegistry.createUnsafe(this).apply { currentState = initialState } + } + + private class Fixture { + var isEnabled = true + val scope = Scope(SentryOptions()) + val coordinator = NavLeaseCoordinator() + val scopes = + mock().also { scopes -> + whenever(scopes.options).thenReturn(SentryOptions()) + doAnswer { + if (isEnabled) { + (it.arguments[0] as ScopeCallback).run(scope) + } + null + } + .whenever(scopes) + .configureScope(any()) + whenever(scopes.startTransaction(any(), any())) + .thenAnswer { mock() } + } + + fun navScopes(): NavScopes = NavScopes(scopes, coordinator) + + private var leaseProbeCount = 0 + + /** + * Whether [navScopes] holds the nav lease, observed through whether a screen name it sets + * reaches the real [scope]. Overwrites the scope's screen. + */ + fun hasLease(navScopes: NavScopes): Boolean { + val probe = "lease probe ${++leaseProbeCount}" + navScopes.configureScope { it.screen = probe } + return scope.screen == probe + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt new file mode 100644 index 00000000000..a5b74ff4a2c --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt @@ -0,0 +1,221 @@ +package io.sentry.compose.navigation3.lifecycle + +import com.google.common.truth.Truth.assertThat +import io.sentry.Scope +import io.sentry.SentryOptions +import org.junit.Test + +class StagedNavDataTest { + + private val realScope = Scope(SentryOptions.empty()) + private val stagedNavData = StagedNavData() + + @Test + fun `stageScreen() - stages the screen without changing the real scope`() { + realScope.screen = "Host" + + stagedNavData.stageScreen("Profile") + + assertThat(stagedNavData.screen).isEqualTo("Profile") + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `publishScreen() - publishes the staged screen`() { + stagedNavData.stageScreen("Profile") + + stagedNavData.publishScreen(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `stageNavigationContext() - leaves the real navigation context unchanged`() { + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishNavigationContext() - publishes the staged navigation context`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + + stagedNavData.publishNavigationContext(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - publishes the staged screen`() { + stagedNavData.stageScreen("Profile") + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `publishTo() - republishes an already published screen that was since replaced`() { + stagedNavData.stageScreen("Profile") + stagedNavData.publishScreen(realScope) + realScope.screen = "Host" + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `publishTo() - leaves the real screen alone when no screen was staged`() { + realScope.screen = "Host" + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `publishTo() - clears the real screen when the staged screen is null`() { + realScope.screen = "Previous" + stagedNavData.stageScreen(null) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isNull() + } + + @Test + fun `publishTo() - publishes the staged navigation context`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - republishes an already published navigation context that was since replaced`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + stagedNavData.publishNavigationContext(realScope) + realScope.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Host")) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - leaves the real navigation context alone when none was staged`() { + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishTo() - clears the real navigation context when the staged context is null`() { + realScope.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Previous")) + stagedNavData.stageNavigationContext(null) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - preserves a host screen set after publication without a staged screen`() { + stagedNavData.publishTo(realScope) + realScope.screen = "Host" + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `clearPublishedStateFrom() - clears screen and navigation context published by this holder`() { + stagedNavData.stageScreen("Profile") + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + stagedNavData.publishTo(realScope) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isNull() + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - clears the published screen and navigation context after newer values are staged`() { + stagedNavData.stageScreen("Home") + stagedNavData.stageNavigationContext(mapOf("route" to "Home")) + stagedNavData.publishTo(realScope) + stagedNavData.stageScreen("Profile") + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isNull() + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - preserves equal but distinct host replacements`() { + stagedNavData.stageScreen(String(charArrayOf('H', 'o', 'm', 'e'))) + stagedNavData.stageNavigationContext(mapOf("route" to "Home")) + stagedNavData.publishTo(realScope) + val hostScreen = String(charArrayOf('H', 'o', 'm', 'e')) + val hostContext = mapOf("route" to "Home") + realScope.screen = hostScreen + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isSameInstanceAs(hostScreen) + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `clearPublishedStateFrom() - preserves the real screen and navigation context before publication`() { + realScope.screen = "Host" + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + stagedNavData.stageScreen("Staged") + stagedNavData.stageNavigationContext(mapOf("route" to "Staged")) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishTo() - leaves the real propagation context alone when no rotation was deferred`() { + val realContext = realScope.propagationContext + + stagedNavData.publishTo(realScope) + + assertThat(realScope.propagationContext).isSameInstanceAs(realContext) + } + + @Test + fun `publishTo() - performs a deferred propagation context rotation once`() { + stagedNavData.deferPropagationContextRotation() + stagedNavData.deferPropagationContextRotation() + val realContext = realScope.propagationContext + + stagedNavData.publishTo(realScope) + val rotatedContext = realScope.propagationContext + stagedNavData.publishTo(realScope) + + assertThat(rotatedContext).isNotSameInstanceAs(realContext) + assertThat(realScope.propagationContext).isSameInstanceAs(rotatedContext) + } +} diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 4fee4a1b92a..60cbefde95e 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -216,6 +216,7 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.recyclerview) + implementation(libs.androidx.viewpager2) implementation(libs.androidx.browser) implementation(libs.androidx.room3.runtime) implementation(libs.bundles.androidx.room2) @@ -238,6 +239,9 @@ dependencies { debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) + + testImplementation(libs.google.truth) + testImplementation(libs.kotlin.test.junit) } abstract class ToggleNativeLoggingTask : Exec() { diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 4e80ddfd865..da8b00d97a0 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -123,6 +123,30 @@ android:screenOrientation="portrait" android:theme="@style/AppTheme.Main" /> + + + + + + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt index 5b935a01e01..3fad5ffe277 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import io.sentry.compose.navigation3.SentryBackStackEntry @@ -75,9 +77,6 @@ class Nav3Activity : ComponentActivity() { private var routeWorkOptions by mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN)) - private var isTransactionHistoryActive = false - private val transactionHistory = - NavigationTransactionHistory(isActive = { isTransactionHistoryActive }) private var showTransactionHistorySheet by mutableStateOf(false) private var showActivityUiLoadTransactionDelayMessage = false @@ -99,14 +98,14 @@ class Nav3Activity : ComponentActivity() { cancelCurrentActivityUiLoadTransaction() } - transactionHistory.install() + NavigationTransactionHistory.install() setContent { Nav3SampleTheme { Nav3SampleApp( performanceState = performanceState, configuration = configuration, - transactions = transactionHistory.transactions, + transactions = NavigationTransactionHistory.transactions, showActivityUiLoadTransactionDelayMessage = showActivityUiLoadTransactionDelayMessage, routeWorkOptions = routeWorkOptions, showTransactionHistorySheet = showTransactionHistorySheet, @@ -124,13 +123,7 @@ class Nav3Activity : ComponentActivity() { } } - override fun onStart() { - super.onStart() - isTransactionHistoryActive = true - } - override fun onStop() { - isTransactionHistoryActive = false performanceState.stopAutomaticWork() super.onStop() } @@ -139,7 +132,6 @@ class Nav3Activity : ComponentActivity() { if (isFinishing) { previousConfig.applyToCurrentOptions() } - transactionHistory.uninstall() super.onDestroy() } @@ -361,7 +353,9 @@ private fun Nav3SampleApp( } Scaffold( - modifier = Modifier.fillMaxSize().safeDrawingPadding(), + // Expose test tags as resource IDs so adb/uiautomator can target them. + modifier = + Modifier.fillMaxSize().safeDrawingPadding().semantics { testTagsAsResourceId = true }, containerColor = MaterialTheme.colorScheme.background, topBar = { Nav3TopBar( @@ -464,6 +458,28 @@ private fun Nav3SampleApp( ) } } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + MultiActivityRoute { + activity?.startActivity( + Intent(activity, Nav3ProductsActivity::class.java) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) + ) + } + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + MultiFragmentRoute { + activity?.startActivity( + Intent(activity, Nav3FragmentsActivity::class.java) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) + ) + } + } + } entry { route -> TracedNav3Route(route, selectedScenario) { Nav3RouteWorkEffect(route, routeWorkOptions) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt new file mode 100644 index 00000000000..8f825f77f22 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt @@ -0,0 +1,491 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.currentStateAsState +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner +import io.sentry.compose.SentryModifier.sentryTag +import io.sentry.compose.navigation3.SentryBackStackEntry +import io.sentry.compose.navigation3.SentryNavEffect +import io.sentry.compose.navigation3.SentryNavOptions +import io.sentry.samples.android.navigation.common.NavigationSampleConfig +import io.sentry.samples.android.navigation.common.RouteWorkOption +import io.sentry.samples.android.navigation.common.hasOnlyActivityUiLoadTransactions +import io.sentry.samples.android.navigation.common.showRouteWorkDialog +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/* + * Activities that each own a Nav3 root (back stack, `SentryNavEffect`, and `NavDisplay`), which let us + * exercise how separate effects hand off during Activity transactions. + */ + +/** Keeps its navigation effect alive while checkout runs in another Activity. */ +class Nav3ProductsActivity : ComponentActivity() { + + private var backStack: SnapshotStateList? = null + + /** An order ID returned before [backStack] exists (e.g., after Activity recreation). */ + private var pendingOrderId: String? = null + + // Pushes a destination from the result callback itself, which runs before this Activity resumes, + // like an app that navigates in response to an Activity result. + private val checkoutForResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val orderId = result.data?.getStringExtra(EXTRA_ORDER_ID) ?: return@registerForActivityResult + backStack?.add(Nav3Route.Confirmation(orderId)) ?: run { pendingOrderId = orderId } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration() + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + setContent { + val stack = rememberSaveableNav3BackStack(Nav3Route.ProductList) + SideEffect { + backStack = stack + pendingOrderId?.let { orderId -> + pendingOrderId = null + stack.add(Nav3Route.Confirmation(orderId)) + } + } + Nav3SampleTheme { + MultiRootNavDisplay( + activity = this, + title = "Products Activity", + initialRoute = Nav3Route.ProductList, + scenario = Nav3Scenario.MULTI_ACTIVITY, + configuration = configuration, + onFinish = { finish() }, + backStack = stack, + onCheckout = { productId -> startActivity(checkoutIntent(productId, configuration)) }, + productDetailActions = + listOf( + ProductDetailAction( + label = "Go to Translucent Checkout", + testTag = nav3TestTag("product_detail_go_to_translucent_checkout"), + onClick = { productId -> + startActivity( + checkoutIntent(productId, configuration) + .setClass(this, Nav3TranslucentCheckoutActivity::class.java) + ) + }, + ), + ProductDetailAction( + label = "Go to Checkout for Result", + testTag = nav3TestTag("product_detail_go_to_checkout_for_result"), + onClick = { productId -> + checkoutForResult.launch( + checkoutIntent(productId, configuration).putExtra(EXTRA_RETURN_RESULT, true) + ) + }, + ), + ProductDetailAction( + label = "Go to Checkout (Crash on First Composition)", + testTag = nav3TestTag("product_detail_go_to_checkout_crash"), + onClick = { productId -> + startActivity( + checkoutIntent(productId, configuration) + .putExtra(EXTRA_CRASH_ON_FIRST_COMPOSITION, true) + ) + }, + ), + ), + ) + } + } + } + + override fun onDestroy() { + backStack = null + super.onDestroy() + } + + private fun checkoutIntent(productId: String, configuration: NavigationSampleConfig): Intent = + Intent(this, Nav3CheckoutActivity::class.java) + .putExtra(EXTRA_PRODUCT_ID, productId) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) +} + +/** Owns the checkout back stack independently of the Products Activity. */ +open class Nav3CheckoutActivity : ComponentActivity() { + + /** Whether to show checkout as a sheet, leaving the Activity underneath partly visible. */ + protected open val showAsSheet: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration() + val productId = intent.getStringExtra(EXTRA_PRODUCT_ID) ?: "42" + val returnsResult = intent.getBooleanExtra(EXTRA_RETURN_RESULT, false) + // Only crash on the first launch, not when the system restores this Activity. + val crashOnFirstComposition = + savedInstanceState == null && intent.getBooleanExtra(EXTRA_CRASH_ON_FIRST_COMPOSITION, false) + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + setContent { + Nav3SampleTheme { + if (showAsSheet) { + CheckoutSheet { + CheckoutRoot(productId, configuration, returnsResult, crashOnFirstComposition) + } + } else { + CheckoutRoot(productId, configuration, returnsResult, crashOnFirstComposition) + } + } + } + } + + @Composable + private fun CheckoutRoot( + productId: String, + configuration: NavigationSampleConfig, + returnsResult: Boolean, + crashOnFirstComposition: Boolean, + ) { + MultiRootNavDisplay( + activity = this, + title = if (showAsSheet) "Translucent Checkout Activity" else "Checkout Activity", + initialRoute = Nav3Route.Checkout(productId), + scenario = Nav3Scenario.MULTI_ACTIVITY, + configuration = configuration, + onFinish = { finish() }, + onCompleteOrder = + if (returnsResult) { + { orderId -> + setResult(RESULT_OK, Intent().putExtra(EXTRA_ORDER_ID, orderId)) + finish() + } + } else { + null + }, + crashOnFirstComposition = crashOnFirstComposition, + ) + } +} + +/** + * Checkout in a translucent Activity. The Products Activity underneath stays visible, so it's + * paused rather than stopped while checkout is open. + */ +class Nav3TranslucentCheckoutActivity : Nav3CheckoutActivity() { + override val showAsSheet: Boolean = true +} + +/** Shows [content] as a bottom sheet over a scrim, leaving the top of the screen see-through. */ +@Composable +private fun CheckoutSheet(content: @Composable () -> Unit) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f))) { + Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.7f).align(Alignment.BottomCenter)) { + content() + } + } +} + +/** An extra Product Detail button, given the product's ID when clicked. */ +internal class ProductDetailAction( + val label: String, + val testTag: String, + val onClick: (productId: String) -> Unit, +) + +@Composable +internal fun MultiActivityRoute(onOpenProducts: () -> Unit) { + RouteScaffold( + routeSpec = Nav3Route.MultiActivity.routeSpec(), + testTagPrefix = nav3TestTag("route_multi_activity"), + cardContent = { + RouteButton( + "Open products Activity", + onClick = onOpenProducts, + testTag = nav3TestTag("multi_activity_open_products"), + ) + }, + ) +} + +/** + * A self-contained Nav3 root (back stack, [SentryNavEffect], and [NavDisplay]) shared by the + * Multi-Activity and Multi-Fragment samples, so each Activity or Fragment owns its own effect. + * + * @param backStack This root's back stack. Pass one to navigate it from outside the composition. + * @param onCheckout Opens checkout outside this root (e.g., another Activity or Fragment). When + * `null`, checkout is pushed onto this root's own back stack. + * @param productDetailActions Additional Product Detail buttons. Product Detail always offers a + * button that pushes a confirmation after a delay, so the push can happen while this root is + * covered by another one. + * @param onCompleteOrder Handles Checkout's "Complete Order" outside this root (e.g., by returning + * a result). When `null`, the confirmation is pushed onto this root's own back stack. + * @param crashOnFirstComposition Crashes while composing the Checkout destination, i.e., while the + * root's first screen is being created, to check that the crash is attributed to that screen. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun MultiRootNavDisplay( + activity: ComponentActivity, + title: String, + initialRoute: Nav3Route, + scenario: Nav3Scenario, + configuration: NavigationSampleConfig, + onFinish: () -> Unit, + backStack: SnapshotStateList = rememberSaveableNav3BackStack(initialRoute), + onCheckout: ((String) -> Unit)? = null, + productDetailActions: List = emptyList(), + onCompleteOrder: ((orderId: String) -> Unit)? = null, + crashOnFirstComposition: Boolean = false, +) { + val coroutineScope = rememberCoroutineScope() + var routeWorkOptions by remember { mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN)) } + var showTransactionHistorySheet by remember { mutableStateOf(false) } + var showCrashConfirmation by remember { mutableStateOf(false) } + val options = + remember(configuration) { + SentryNavOptions { + enableNavigationBreadcrumbs = configuration.enableNavigationBreadcrumbs + enableNavigationTransactions = configuration.enableNavigationTransactions + captureBackStack = configuration.captureBackStack + maxCapturedBackStackEntries = configuration.maxCapturedBackStackEntries + } + } + SentryNavEffect( + backStack = backStack, + backStackEntryMapper = { route -> + SentryBackStackEntry(name = route.routeName, arguments = route.arguments) + }, + options = options, + ) + val onBack: () -> Unit = { + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + onFinish() + } + } + Scaffold( + // Expose test tags as resource IDs so adb/uiautomator can target them. + modifier = + Modifier.fillMaxSize().safeDrawingPadding().semantics { testTagsAsResourceId = true }, + topBar = { + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { showTransactionHistorySheet = true }, + modifier = + Modifier.sentryTag(nav3InteractionTag("Recent Transactions")) + .testTag(nav3TestTag("top_bar_recent_transactions")), + ) { + Icon(Icons.Filled.AccountTree, contentDescription = "Recent transactions") + } + IconButton( + onClick = { + showRouteWorkDialog(activity, routeWorkOptions) { routeWorkOptions = it } + }, + modifier = + Modifier.sentryTag(nav3InteractionTag("Route Work Settings")) + .testTag(nav3TestTag("top_bar_route_work_settings")), + ) { + Icon(Icons.Filled.Settings, contentDescription = "Route work settings") + } + } + Text("Current route: ${backStack.last().displayRoute()}") + } + }, + bottomBar = { + SentryControls( + onCaptureException = { captureSampleException("Nav3 ${scenario.label}") }, + onCrashApp = { showCrashConfirmation = true }, + ) + }, + ) { padding -> + // Only handle back while this root is resumed. Otherwise, a root that's still composed but no + // longer current (e.g., a Fragment animating out, or a preloaded ViewPager2 page) could consume + // back presses meant for the current root, popping a back stack the user can't see. + val isResumed = + LocalLifecycleOwner.current.lifecycle + .currentStateAsState() + .value + .isAtLeast(Lifecycle.State.RESUMED) + // Pass the parent explicitly: it's null while this root's view is detached (e.g., a recycled + // ViewPager2 page that still recomposes), where the default parameter would throw. + val parentDispatcherOwner = LocalNavigationEventDispatcherOwner.current + CompositionLocalProvider( + LocalNavigationEventDispatcherOwner provides + rememberNavigationEventDispatcherOwner(enabled = isResumed, parent = parentDispatcherOwner) + ) { + NavDisplay( + backStack = backStack, + modifier = Modifier.fillMaxSize().padding(padding), + onBack = onBack, + entryProvider = + entryProvider { + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductListRoute(backStack) + } + } + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductDetailRoute( + route = route, + backStack = backStack, + onCheckout = { + onCheckout?.invoke(route.productId) + ?: backStack.add(Nav3Route.Checkout(route.productId)) + }, + showOverlays = false, + extraActions = { + RouteButton( + "Push Confirmation in $DELAYED_NAVIGATION_SECONDS s", + onClick = { + coroutineScope.launch { + delay(DELAYED_NAVIGATION_SECONDS * 1_000L) + backStack += Nav3Route.Confirmation("delayed-${route.productId}") + } + }, + testTag = nav3TestTag("product_detail_delayed_confirmation"), + ) + productDetailActions.forEach { action -> + RouteButton( + action.label, + onClick = { action.onClick(route.productId) }, + testTag = action.testTag, + ) + } + }, + ) + } + } + entry { route -> + if (crashOnFirstComposition) { + throw RuntimeException( + "Fatal Nav3 ${scenario.label} crash during first composition" + ) + } + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + CheckoutRoute( + route = route, + backStack = backStack, + onCompleteOrder = onCompleteOrder ?: { backStack += Nav3Route.Confirmation(it) }, + ) + } + } + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ConfirmationRoute(route, backStack, rootRoute = initialRoute) + } + } + }, + ) + } + } + if (showCrashConfirmation) { + AlertDialog( + onDismissRequest = { showCrashConfirmation = false }, + title = { Text("Crash app?") }, + text = { Text("This will throw an uncaught exception and close the sample app.") }, + dismissButton = { + TextButton(onClick = { showCrashConfirmation = false }) { Text("Cancel") } + }, + confirmButton = { + TextButton( + onClick = { + showCrashConfirmation = false + crashSampleApp("Nav3 ${scenario.label}") + } + ) { + Text("Crash") + } + }, + ) + } + if (showTransactionHistorySheet) { + NavigationTransactionHistorySheet( + sampleName = "Nav3", + transactions = NavigationTransactionHistory.transactions, + showActivityUiLoadTransactionDelayMessage = configuration.hasOnlyActivityUiLoadTransactions, + onDismissRequest = { showTransactionHistorySheet = false }, + onOpenTransaction = { url -> + activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }, + onDumpTransactionUrl = { url -> + Log.i("Nav3MultiActivity", "Sentry transaction URL: $url") + Toast.makeText(activity, "Dumped transaction URL to logcat.", Toast.LENGTH_SHORT).show() + }, + onCopyTransactionUrl = { url -> + val clipboard = activity.getSystemService(ClipboardManager::class.java) + clipboard.setPrimaryClip(ClipData.newPlainText("Sentry transaction URL", url)) + Toast.makeText(activity, "Copied transaction URL to clipboard.", Toast.LENGTH_SHORT).show() + }, + ) + } +} + +private const val DELAYED_NAVIGATION_SECONDS = 5 +private const val EXTRA_PRODUCT_ID = "io.sentry.samples.android.navigation.nav3.product_id" +private const val EXTRA_RETURN_RESULT = "io.sentry.samples.android.navigation.nav3.return_result" +private const val EXTRA_ORDER_ID = "io.sentry.samples.android.navigation.nav3.order_id" +private const val EXTRA_CRASH_ON_FIRST_COMPOSITION = + "io.sentry.samples.android.navigation.nav3.crash_on_first_composition" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt new file mode 100644 index 00000000000..1ca11284eeb --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt @@ -0,0 +1,420 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp +import androidx.core.graphics.Insets +import androidx.core.os.bundleOf +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.commit +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import io.sentry.samples.android.R +import io.sentry.samples.android.navigation.common.NavigationSampleConfig + +/** + * Hosts Fragments that each own a Nav3 root (back stack, `SentryNavEffect`, and `NavDisplay`), to + * exercise how separate effects hand off during Fragment transactions: + * + * - `replace` + back stack (Products → Checkout), optionally with a slow enter animation; + * - a full-screen `DialogFragment` overlay whose host Fragment stays resumed; + * - a `ViewPager2` of Fragments, where neighboring pages are preloaded at STARTED. + */ +// FragmentActivity rather than AppCompatActivity: AppCompatActivity.setContentView doesn't install +// the NavigationEventDispatcherOwner that NavDisplay needs on the view tree. +class Nav3FragmentsActivity : FragmentActivity() { + + /** When true, Fragment `replace` uses a slow enter animation. */ + internal var slowTransitions by mutableStateOf(false) + + internal val configuration: NavigationSampleConfig + get() = (intent.extras ?: Bundle()).nav3SampleConfiguration() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + + val controls = + ComposeView(this).apply { + setContent { + Nav3SampleTheme { + MultiFragmentControls( + slowTransitions = slowTransitions, + onSlowTransitionsChanged = { slowTransitions = it }, + onOpenCheckoutSheet = ::openCheckoutSheet, + onOpenPager = ::openPager, + ) + } + } + } + val container = FragmentContainerView(this).apply { id = R.id.nav3_multi_fragment_container } + // The controls above already pad for the status bar; keep Fragments from padding for it again. + ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets -> + WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.NONE) + .setInsets(WindowInsetsCompat.Type.displayCutout(), Insets.NONE) + .build() + } + setContentView( + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(controls, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + addView(container, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) + } + ) + + if (savedInstanceState == null) { + supportFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.nav3_multi_fragment_container, Nav3RootFragment.products()) + } + } + } + + internal fun openCheckoutFragment(productId: String) { + supportFragmentManager.commit { + setReorderingAllowed(true) + if (slowTransitions) { + setCustomAnimations( + R.anim.nav3_slow_fade_in, + R.anim.nav3_slow_hold, + R.anim.nav3_slow_fade_in, + R.anim.nav3_slow_hold, + ) + } else { + setCustomAnimations( + android.R.anim.fade_in, + android.R.anim.fade_out, + android.R.anim.fade_in, + android.R.anim.fade_out, + ) + } + replace(R.id.nav3_multi_fragment_container, Nav3RootFragment.checkout(productId)) + addToBackStack("checkout") + } + } + + private fun openPager() { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.nav3_multi_fragment_container, Nav3PagerFragment()) + addToBackStack("pager") + } + } + + private fun openCheckoutSheet() { + Nav3CheckoutSheetFragment().show(supportFragmentManager, "checkout_sheet") + } + + /** Pops this Activity's Fragment back stack, or finishes the Activity when it's empty. */ + internal fun finishRoot() { + if (supportFragmentManager.backStackEntryCount > 0) { + supportFragmentManager.popBackStack() + } else { + finish() + } + } +} + +/** A Fragment that owns one Nav3 root. Pager pages are child Fragments of [Nav3PagerFragment]. */ +class Nav3RootFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val args = requireArguments() + val host = requireNotNull(args.getString(ARG_HOST)) + val activity = requireActivity() as Nav3FragmentsActivity + val initialRoute = + when (args.getString(ARG_KIND)) { + KIND_CHECKOUT -> Nav3Route.Checkout(requireNotNull(args.getString(ARG_PRODUCT_ID))) + KIND_PAGE -> pageRoots[args.getInt(ARG_PAGE)] + else -> Nav3Route.ProductList + } + val opensCheckoutFragment = args.getString(ARG_KIND) == KIND_PRODUCTS + + return nav3RootComposeView( + activity = activity, + host = host, + initialRoute = initialRoute, + configuration = activity.configuration, + onFinish = activity::finishRoot, + onCheckout = if (opensCheckoutFragment) activity::openCheckoutFragment else null, + ) + } + + internal companion object { + private const val ARG_KIND = "kind" + private const val ARG_HOST = "host" + private const val ARG_PRODUCT_ID = "product_id" + private const val ARG_PAGE = "page" + private const val KIND_PRODUCTS = "products" + private const val KIND_CHECKOUT = "checkout" + private const val KIND_PAGE = "page" + + internal val pageRoots: List = + listOf(Nav3Route.ProductList, Nav3Route.Checkout("7"), Nav3Route.Confirmation("page-2")) + + fun products() = + Nav3RootFragment().apply { + arguments = bundleOf(ARG_KIND to KIND_PRODUCTS, ARG_HOST to "ProductsFragment") + } + + fun checkout(productId: String) = + Nav3RootFragment().apply { + arguments = + bundleOf( + ARG_KIND to KIND_CHECKOUT, + ARG_HOST to "CheckoutFragment", + ARG_PRODUCT_ID to productId, + ) + } + + fun page(index: Int) = + Nav3RootFragment().apply { + arguments = bundleOf(ARG_KIND to KIND_PAGE, ARG_HOST to "Page$index", ARG_PAGE to index) + } + } +} + +/** A full-screen dialog with its own Nav3 root. The Fragment underneath stays resumed. */ +class Nav3CheckoutSheetFragment : DialogFragment() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, R.style.AppTheme_Main) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val activity = requireActivity() as Nav3FragmentsActivity + return nav3RootComposeView( + activity = activity, + host = "CheckoutSheet", + initialRoute = Nav3Route.Checkout("42"), + configuration = activity.configuration, + onFinish = ::dismiss, + onCheckout = null, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout(MATCH_PARENT, MATCH_PARENT) + } +} + +/** + * A `ViewPager2` whose pages each own a Nav3 root. With `offscreenPageLimit = 1`, the neighbors of + * the current page are created and capped at STARTED, while only the current page is RESUMED. + */ +class Nav3PagerFragment : Fragment() { + + private var currentPage by mutableIntStateOf(0) + private var pager: ViewPager2? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val viewPager = + ViewPager2(requireContext()).apply { + id = R.id.nav3_multi_fragment_pager + offscreenPageLimit = 1 + } + pager = viewPager + val header = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + Nav3SampleTheme { + PagerHeader(currentPage = currentPage, onSelectPage = { viewPager.currentItem = it }) + } + } + } + return LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + addView(header, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + addView(viewPager, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val viewPager = requireNotNull(pager) + viewPager.adapter = + object : FragmentStateAdapter(childFragmentManager, viewLifecycleOwner.lifecycle) { + override fun getItemCount(): Int = Nav3RootFragment.pageRoots.size + + override fun createFragment(position: Int): Fragment = Nav3RootFragment.page(position) + } + viewPager.registerOnPageChangeCallback( + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + currentPage = position + } + } + ) + } + + override fun onDestroyView() { + pager = null + super.onDestroyView() + } +} + +@Composable +internal fun MultiFragmentRoute(onOpenFragments: () -> Unit) { + RouteScaffold( + routeSpec = Nav3Route.MultiFragment.routeSpec(), + testTagPrefix = nav3TestTag("route_multi_fragment"), + cardContent = { + RouteButton( + "Open fragments Activity", + onClick = onOpenFragments, + testTag = nav3TestTag("multi_fragment_open_fragments"), + ) + }, + ) +} + +private fun Fragment.nav3RootComposeView( + activity: ComponentActivity, + host: String, + initialRoute: Nav3Route, + configuration: NavigationSampleConfig, + onFinish: () -> Unit, + onCheckout: ((String) -> Unit)?, +): ComposeView = + ComposeView(requireContext()).apply { + // Dispose with the Fragment's view, so the root's SentryNavEffect leaves the composition when + // the Fragment's view is destroyed (e.g., when it's replaced and put on the back stack). + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + Nav3SampleTheme { + CompositionLocalProvider(LocalNav3SampleHost provides host) { + MultiRootNavDisplay( + activity = activity, + title = host, + initialRoute = initialRoute, + scenario = Nav3Scenario.MULTI_FRAGMENT, + configuration = configuration, + onFinish = onFinish, + onCheckout = onCheckout, + ) + } + } + } + } + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun MultiFragmentControls( + slowTransitions: Boolean, + onSlowTransitionsChanged: (Boolean) -> Unit, + onOpenCheckoutSheet: () -> Unit, + onOpenPager: () -> Unit, +) { + Surface( + shadowElevation = 4.dp, + modifier = Modifier.statusBarsPadding().semantics { testTagsAsResourceId = true }, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text("Fragments Activity", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onOpenCheckoutSheet, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_open_checkout_sheet")), + ) { + Text("Checkout sheet") + } + OutlinedButton( + onClick = onOpenPager, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_open_pager")), + ) { + Text("Pager") + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Slow Fragment transitions", modifier = Modifier.weight(1f)) + Switch( + checked = slowTransitions, + onCheckedChange = onSlowTransitionsChanged, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_slow_transitions")), + ) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun PagerHeader(currentPage: Int, onSelectPage: (Int) -> Unit) { + Column( + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).semantics { + testTagsAsResourceId = true + } + ) { + Text( + "ViewPager2 page $currentPage (neighbors preloaded at STARTED)", + modifier = Modifier.testTag(nav3TestTag("multi_fragment_current_page")), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Nav3RootFragment.pageRoots.indices.forEach { index -> + OutlinedButton( + onClick = { onSelectPage(index) }, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_page_$index")), + ) { + Text("Page $index") + } + } + } + } +} + +private const val MATCH_PARENT = ViewGroup.LayoutParams.MATCH_PARENT +private const val WRAP_CONTENT = ViewGroup.LayoutParams.WRAP_CONTENT diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt index 1d4b933fdc3..2879693b0de 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt @@ -28,15 +28,18 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.text.font.FontWeight @@ -47,6 +50,7 @@ import io.sentry.Sentry import io.sentry.compose.SentryModifier.sentryTag import io.sentry.compose.SentryTraced import io.sentry.samples.android.R +import io.sentry.samples.android.getActivity import io.sentry.samples.android.navigation.common.RouteNames import io.sentry.samples.android.navigation.common.RouteSpec import io.sentry.samples.android.navigation.common.RouteSpecs @@ -58,6 +62,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import retrofit2.HttpException +/** + * Names the Activity or Fragment hosting the current Nav3 root, for the sample's transaction tags. + * Defaults to the Activity's class name when unset. + */ +internal val LocalNav3SampleHost = staticCompositionLocalOf { null } + @ExperimentalComposeUiApi @Composable internal fun TracedNav3Route( @@ -65,7 +75,10 @@ internal fun TracedNav3Route( scenario: Nav3Scenario, content: @Composable BoxScope.() -> Unit, ) { - tagCurrentNav3Scenario(scenario) + val activityName = + LocalNav3SampleHost.current ?: LocalContext.current.getActivity().javaClass.simpleName + // Tag after composition is applied so abandoned compositions don't tag anything. + SideEffect { tagCurrentNav3Scenario(scenario, activityName) } SentryTraced( tag = "Nav3 /${route.routeName}", enableUserInteractionTracing = false, @@ -73,8 +86,19 @@ internal fun TracedNav3Route( ) } -private fun tagCurrentNav3Scenario(scenario: Nav3Scenario) { - Sentry.getSpan()?.setTag("sample_nav3_scenario", scenario.label) +private fun tagCurrentNav3Scenario(scenario: Nav3Scenario, activityName: String) { + val sessionId = NavigationTransactionHistory.sessionId + Sentry.getSpan()?.let { span -> + span.setTag(NAV3_SCENARIO_TAG, scenario.label) + span.setTag(NAVIGATION_SAMPLE_ACTIVITY_TAG, activityName) + span.setTag(NAVIGATION_SAMPLE_SESSION_TAG, sessionId) + } + Sentry.configureScope { scope -> + scope.withTransaction { transaction -> + transaction?.setTag(NAVIGATION_SAMPLE_ACTIVITY_TAG, activityName) + transaction?.setTag(NAVIGATION_SAMPLE_SESSION_TAG, sessionId) + } + } tagCurrentNavigationSampleScenario(scenario.label) } @@ -496,6 +520,9 @@ internal fun ProductListRoute(backStack: SnapshotStateList) { internal fun ProductDetailRoute( route: Nav3Route.ProductDetail, backStack: SnapshotStateList, + onCheckout: () -> Unit = { backStack.add(Nav3Route.Checkout(route.productId)) }, + showOverlays: Boolean = true, + extraActions: @Composable ColumnScope.() -> Unit = {}, ) { LaunchedEffect(route.productId, route.source, route.campaign) { recordSimulatedBackgroundSpan(RouteNames.PRODUCT_DETAIL) @@ -508,21 +535,24 @@ internal fun ProductDetailRoute( RouteSpecs.productDetail.displayArguments(route.arguments).forEach { (label, value) -> RouteInfo(label, value) } - RouteButton( - "Show Promo Dialog", - onClick = { backStack.add(Nav3Route.PromoDialog("detail-${route.productId}")) }, - testTag = nav3TestTag("product_detail_show_promo_dialog"), - ) - RouteButton( - "Open Share Sheet", - onClick = { backStack.add(Nav3Route.ShareSheet(route.productId)) }, - testTag = nav3TestTag("product_detail_open_share_sheet"), - ) + if (showOverlays) { + RouteButton( + "Show Promo Dialog", + onClick = { backStack.add(Nav3Route.PromoDialog("detail-${route.productId}")) }, + testTag = nav3TestTag("product_detail_show_promo_dialog"), + ) + RouteButton( + "Open Share Sheet", + onClick = { backStack.add(Nav3Route.ShareSheet(route.productId)) }, + testTag = nav3TestTag("product_detail_open_share_sheet"), + ) + } RouteButton( "Go to Checkout", - onClick = { backStack.add(Nav3Route.Checkout(route.productId)) }, + onClick = onCheckout, testTag = nav3TestTag("product_detail_go_to_checkout"), ) + extraActions() }, footerContent = { RouteButton( @@ -538,6 +568,7 @@ internal fun ProductDetailRoute( internal fun CheckoutRoute( route: Nav3Route.Checkout, backStack: SnapshotStateList, + onCompleteOrder: (orderId: String) -> Unit = { backStack.add(Nav3Route.Confirmation(it)) }, ) { RouteScaffold( routeSpec = RouteSpecs.checkout, @@ -548,7 +579,7 @@ internal fun CheckoutRoute( } RouteButton( "Complete Order", - onClick = { backStack.add(Nav3Route.Confirmation(orderId = "order-${route.productId}")) }, + onClick = { onCompleteOrder("order-${route.productId}") }, testTag = nav3TestTag("checkout_complete_order"), ) }, diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt index b9972a6cd76..554a5d3c3d3 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt @@ -26,6 +26,22 @@ internal fun Nav3Route.routeSpec(): RouteSpec = "Reuses the single-stack shopping flow while simulating a power user who starts their " + "own manual transactions.", ) + Nav3Route.MultiActivity -> + RouteSpec( + routeName = Nav3Route.MultiActivity.routeName, + title = "Multi-Activity", + description = + "Tests how SentryNavEffect uses NavScopes to coordinate state updates between " + + "SentryNavEffect instances. Each Activity owns its own SentryNavEffect and NavDisplay.", + ) + Nav3Route.MultiFragment -> + RouteSpec( + routeName = Nav3Route.MultiFragment.routeName, + title = "Multi-Fragment", + description = + "Tests SentryNavEffect when each Fragment owns its own SentryNavEffect and NavDisplay: " + + "Fragment replace transitions, a dialog overlay, and a ViewPager2 of Fragments.", + ) Nav3Route.DeepLink -> RouteSpec( routeName = Nav3Route.DeepLink.routeName, @@ -69,6 +85,8 @@ private fun Nav3Route.toSavedState(): Bundle = Nav3Route.Landing -> putString("type", "landing") Nav3Route.SingleStack -> putString("type", "single_stack") Nav3Route.Custom -> putString("type", "custom") + Nav3Route.MultiActivity -> putString("type", "multi_activity") + Nav3Route.MultiFragment -> putString("type", "multi_fragment") Nav3Route.DeepLink -> putString("type", "deep_link") Nav3Route.ProductList -> putString("type", "product_list") is Nav3Route.ProductDetail -> { @@ -108,6 +126,8 @@ private fun Bundle.toNav3Route(): Nav3Route { "landing" -> Nav3Route.Landing "single_stack" -> Nav3Route.SingleStack "custom" -> Nav3Route.Custom + "multi_activity" -> Nav3Route.MultiActivity + "multi_fragment" -> Nav3Route.MultiFragment "deep_link" -> Nav3Route.DeepLink "product_list" -> Nav3Route.ProductList "product_detail" -> { @@ -148,6 +168,8 @@ internal fun SnapshotStateList.openScenario(scenario: Nav3Scenario) { Nav3Scenario.LANDING -> resetTo(Nav3Route.Landing) Nav3Scenario.SINGLE_STACK -> resetTo(Nav3Route.SingleStack) Nav3Scenario.CUSTOM -> resetTo(Nav3Route.Custom) + Nav3Scenario.MULTI_ACTIVITY -> resetTo(Nav3Route.MultiActivity) + Nav3Scenario.MULTI_FRAGMENT -> resetTo(Nav3Route.MultiFragment) Nav3Scenario.DEEP_LINK -> resetTo(Nav3Route.DeepLink) Nav3Scenario.MULTIPANE -> resetTo(Nav3Route.Multipane) Nav3Scenario.MULTIPLE_STACKS -> resetTo(Nav3Route.Multistack) @@ -211,6 +233,14 @@ internal sealed interface Nav3Route { override val routeName: String = "Custom" } + data object MultiActivity : Nav3Route { + override val routeName: String = "MultiActivity" + } + + data object MultiFragment : Nav3Route { + override val routeName: String = "MultiFragment" + } + data object DeepLink : Nav3Route { override val routeName: String = RouteNames.DEEP_LINK } @@ -282,6 +312,8 @@ internal enum class Nav3Scenario(val label: String, val showTab: Boolean = true) SINGLE_STACK("Single Stack"), DEEP_LINK("Deep Link"), CUSTOM("Custom"), + MULTI_ACTIVITY("Multi-Activity"), + MULTI_FRAGMENT("Multi-Fragment"), MULTIPANE("Multipane"), MULTIPLE_STACKS("Multistack"), PERFORMANCE("Performance"), @@ -293,6 +325,8 @@ internal val Nav3Scenario.initialRoute: Nav3Route Nav3Scenario.LANDING -> Nav3Route.Landing Nav3Scenario.SINGLE_STACK -> Nav3Route.SingleStack Nav3Scenario.CUSTOM -> Nav3Route.Custom + Nav3Scenario.MULTI_ACTIVITY -> Nav3Route.MultiActivity + Nav3Scenario.MULTI_FRAGMENT -> Nav3Route.MultiFragment Nav3Scenario.DEEP_LINK -> Nav3Route.DeepLink Nav3Scenario.MULTIPANE -> Nav3Route.Multipane Nav3Scenario.MULTIPLE_STACKS -> Nav3Route.Multistack diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt index 4a9b77227b1..e68421824b5 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt @@ -73,7 +73,9 @@ internal const val SENTRY_FLUSH_TIMEOUT_MILLIS = 5000L private const val BACKGROUND_WORK_MILLIS = 1000L private const val NAV2_SCENARIO_TAG = "sample_nav2_scenario" -private const val NAV3_SCENARIO_TAG = "sample_nav3_scenario" +internal const val NAV3_SCENARIO_TAG = "sample_nav3_scenario" +internal const val NAVIGATION_SAMPLE_ACTIVITY_TAG = "sample_navigation_activity" +internal const val NAVIGATION_SAMPLE_SESSION_TAG = "sample_navigation_session" private const val NAVIGATION_SAMPLE_SCENARIO_TAG = "sample_navigation_scenario" private const val UNKNOWN_NAVIGATION_SCENARIO_LABEL = "Unknown" private const val ACTIVITY_UI_LOAD_OP = "ui.load" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt index db945d45198..7b37bbeacab 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt @@ -35,6 +35,7 @@ class Nav3SetupActivity : AppCompatActivity() { onLaunch = { val previousOptions = currentNavigationSampleConfigSnapshot() configuration.applyToCurrentOptions() + NavigationTransactionHistory.startSession() startActivity(nav3LaunchIntent(configuration, previousOptions)) }, ) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt index 38cd6d2039b..21d99a50b2c 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt @@ -8,12 +8,19 @@ import io.sentry.protocol.SentrySpan import io.sentry.protocol.SentryTransaction import io.sentry.samples.android.SampleBeforeSendTransactionHook -/** State holder backing the navigation sample transaction history sheet. */ -internal class NavigationTransactionHistory(private val isActive: () -> Boolean) { +/** Process-wide state holder backing every Nav3 transaction history sheet. */ +internal object NavigationTransactionHistory { - val transactions = mutableStateListOf() + private val store = NavigationTransactionHistoryStore() + @Volatile private var sessionGeneration = 0L + + val transactions: List + get() = store.transactions + + val sessionId: String + get() = sessionGeneration.toString() - private val mainHandler = Handler(Looper.getMainLooper()) + private val mainHandler by lazy { Handler(Looper.getMainLooper()) } private val transactionListener: (SentryTransaction, String?) -> Unit = { transaction, dsn -> add(transaction, dsn) } @@ -24,34 +31,56 @@ internal class NavigationTransactionHistory(private val isActive: () -> Boolean) SampleBeforeSendTransactionHook.addListener(transactionListener) } - fun uninstall() { + fun startSession() { + install() + sessionGeneration++ clear() - SampleBeforeSendTransactionHook.removeListener(transactionListener) } fun clear() { - transactions.clear() + store.clear() } private fun add(transaction: SentryTransaction, dsn: String?) { - if (!isActive()) { + val transactionSessionId = transaction.getTag(NAVIGATION_SAMPLE_SESSION_TAG) + if (transaction.getTag(NAV3_SCENARIO_TAG) == null || transactionSessionId != sessionId) { return } val trace = transaction.toNavigationTransactionTrace(dsn) if (Looper.myLooper() == Looper.getMainLooper()) { - transactions.addMostRecent(trace) + store.add(trace) } else { mainHandler.post { - if (isActive()) { - transactions.addMostRecent(trace) + // The session may have changed while this was queued. + if (transactionSessionId == sessionId) { + store.add(trace) } } } } } +internal class NavigationTransactionHistoryStore( + private val transactionLimit: Int = TRANSACTION_HISTORY_LIMIT +) { + + val transactions = mutableStateListOf() + + fun add(transaction: NavigationTransactionTrace) { + transactions.add(0, transaction) + while (transactions.size > transactionLimit) { + transactions.removeAt(transactions.lastIndex) + } + } + + fun clear() { + transactions.clear() + } +} + internal data class NavigationTransactionTrace( + val activity: String, val name: String, val operation: String, val eventId: String, @@ -73,7 +102,7 @@ internal data class NavigationTraceSpan( val children: List = emptyList(), ) -private fun SentryTransaction.toNavigationTransactionTrace( +internal fun SentryTransaction.toNavigationTransactionTrace( dsn: String? ): NavigationTransactionTrace { val trace = contexts.trace @@ -97,6 +126,7 @@ private fun SentryTransaction.toNavigationTransactionTrace( .sortedBy { span -> span.startOffsetMillis } return NavigationTransactionTrace( + activity = getTag(NAVIGATION_SAMPLE_ACTIVITY_TAG) ?: UNKNOWN_NAVIGATION_ACTIVITY, name = transaction ?: "", operation = trace?.operation ?: "transaction", eventId = eventId, @@ -109,15 +139,6 @@ private fun SentryTransaction.toNavigationTransactionTrace( ) } -private fun MutableList.addMostRecent( - transaction: NavigationTransactionTrace -) { - add(0, transaction) - while (size > TRANSACTION_HISTORY_LIMIT) { - removeAt(lastIndex) - } -} - private fun SentrySpan.toNavigationTraceSpan( transactionStartTimestamp: Double ): NavigationTraceSpan = @@ -172,5 +193,6 @@ private fun String.projectIdFromDsn(): String? = projectId.isNotEmpty() && projectId.all { it.isDigit() } } -private const val TRANSACTION_HISTORY_LIMIT = 10 +internal const val TRANSACTION_HISTORY_LIMIT = 25 private const val SENTRY_SAMPLE_ORG_SLUG = "sentry-sdks" +private const val UNKNOWN_NAVIGATION_ACTIVITY = "Unknown" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt index 0e0038083ea..94a643ba7ca 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt @@ -66,7 +66,8 @@ internal fun NavigationTransactionHistorySheet( modifier = Modifier.testTag(historyTag(sampleName, "title")), ) Text( - "Displays the last 10 finished transactions emitted by the SDK. Transactions with child work appear here after their work finishes.", + "Displays the last $TRANSACTION_HISTORY_LIMIT finished Nav3 transactions emitted by the SDK. " + + "Transactions with child work appear here after their work finishes.", style = MaterialTheme.typography.bodyMedium, ) Column( @@ -133,7 +134,11 @@ private fun TransactionCard( modifier = Modifier.padding(12.dp).testTag(historyTag(sampleName, "card_${index}_content")), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { Column( modifier = Modifier.weight(1f).testTag(historyTag(sampleName, "card_${index}_summary")) ) { @@ -152,12 +157,20 @@ private fun TransactionCard( modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_operation")), ) } - Text( - "Tab: ${transaction.tab}", - style = MaterialTheme.typography.labelLarge, - fontFamily = FontFamily.Monospace, - modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_tab")), - ) + Column(horizontalAlignment = Alignment.End) { + Text( + transaction.activity, + style = MaterialTheme.typography.labelLarge, + fontFamily = FontFamily.Monospace, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_activity")), + ) + Text( + "Tab: ${transaction.tab}", + style = MaterialTheme.typography.labelLarge, + fontFamily = FontFamily.Monospace, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_tab")), + ) + } } TransactionWaterfall(transaction, historyTag(sampleName, "card_${index}_waterfall")) diff --git a/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml new file mode 100644 index 00000000000..a74b152b33c --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml @@ -0,0 +1,7 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml new file mode 100644 index 00000000000..8928cea2bf4 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml @@ -0,0 +1,6 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml b/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml index 918cb756c54..a87670f6c0d 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml @@ -23,4 +23,7 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml b/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml index b01417199a8..73f92d1d263 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml @@ -20,6 +20,12 @@ + + +