Skip to content

Commit 13297ef

Browse files
committed
fix(android): Stop attributing Nav3 data to Activities without a SentryNavEffect
Stop misattributing nav data and spans to a Nav3 root when the user navigates to a new Activity (or ViewPager2 page) that doesn't contain a SentryNavEffect. THE PROBLEM: Assume Home Activity has a `SentryNavEffect` but Plain Activity doesn't. ``` 1. App goes to background: Home ON_PAUSE ──────────────────▶ Home ON_STOP 2. Covered by PlainActivity: Home ON_PAUSE ─▶ Plain ON_RESUME ─▶ Home ON_STOP ^^^^^^^^^^^^^^^ Invisible to Home's SentryNavEffect ``` Case (1) is already handled properly today; case (2) is what this PR addresses. With case (2), spans generated by the Plain Activity will be (wrongly) attributed to the Home Activity's nav transaction, and errors thrown by Plain Activity will contain the Home Activity's nav context (screen name + back stack). Note that cases (1) and (2) require opposite implementations. For (1), the leaseholder should keep its data b/c it's still the last activity the user saw and the user may come back to it. For (2), the leaseholder's data no longer describes what's on the screen and it should be cleared. THE SOLUTION: This commit satisfies both cases by clearing Home's nav context and unbinding its nav transaction when Plain is resumed. If Home returns, its nav context and transaction (if still unfinished) is restored. Implemented by the existing `NavLeaseCoordinator` via the concept of "lease withdrawal". (Note: Fragments not used as ViewPager2 pages don't have the problem discussed above because the Home fragment's view would be destroyed when the Plain fragment showed up, meaning the leaseholder's data would be cleared in case (2).)
1 parent 867786b commit 13297ef

22 files changed

Lines changed: 1580 additions & 192 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixes
66

7+
- Stop attributing errors and spans from an Activity or ViewPager2 page without a `SentryNavEffect` to the most recently visited Navigation 3 root ([#6457](https://github.com/getsentry/sentry-java/pull/6457))
78
- Fix missing Navigation 3 transactions for preloaded ViewPager2 pages and destinations reached while another Activity or Fragment owns navigation tracking ([#6400](https://github.com/getsentry/sentry-java/pull/6400))
89
- Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291))
910
- Before, the start call was ignored and the profiler still stopped at the end of the chunk, even in `TRACE` mode while sampled spans were active. On API 34 and lower, every later profile also stopped after one chunk.

‎sentry-android-navigation3/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ dependencies {
6363
implementation(libs.androidx.lifecycle.runtime.compose)
6464

6565
compileOnly(libs.androidx.compose.runtime)
66+
compileOnly(libs.androidx.compose.ui)
6667

6768
testImplementation(libs.androidx.compose.ui.test.junit4)
6869
testImplementation(libs.androidx.fragment.ktx)

‎sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserver.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package io.sentry.compose.navigation3
22

3-
import androidx.lifecycle.Lifecycle
43
import androidx.lifecycle.LifecycleEventObserver
54
import io.sentry.IScope
65
import io.sentry.IScopes
76
import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator
7+
import io.sentry.compose.navigation3.lifecycle.NavRootInfo
88
import io.sentry.compose.navigation3.lifecycle.NavScopes
99

1010
/**
@@ -35,8 +35,8 @@ internal class CoordinatedBackStackObserver<T : Any>(
3535
private val navScopes = NavScopes(scopes, coordinator)
3636
private val observer = BackStackObserver(navScopes, options, entryMapper)
3737

38-
fun attach(lifecycle: Lifecycle) {
39-
navScopes.attach(lifecycle)
38+
fun attach(rootInfo: NavRootInfo) {
39+
navScopes.attach(rootInfo)
4040
}
4141

4242
fun onBackStackChanged(backStack: List<T>) {

‎sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import androidx.compose.runtime.Composable
44
import androidx.compose.runtime.DisposableEffect
55
import androidx.compose.runtime.remember
66
import androidx.compose.runtime.rememberUpdatedState
7+
import androidx.compose.ui.platform.LocalContext
78
import androidx.lifecycle.compose.LocalLifecycleOwner
89
import io.sentry.IScopes
910
import io.sentry.ScopesAdapter
1011
import io.sentry.SentryOptions
1112
import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator
13+
import io.sentry.compose.navigation3.lifecycle.NavRootInfo
1214
import org.jetbrains.annotations.ApiStatus
1315

1416
/**
@@ -105,6 +107,7 @@ internal fun <T : Any> SentryNavEffect(
105107
) {
106108
val currentBackStackEntryMapper = rememberUpdatedState(backStackEntryMapper)
107109
val lifecycle = LocalLifecycleOwner.current.lifecycle
110+
val context = LocalContext.current
108111

109112
val observer =
110113
remember(scopes, options, lifecycle, coordinator) {
@@ -116,9 +119,13 @@ internal fun <T : Any> SentryNavEffect(
116119
)
117120
}
118121

119-
// Register first so the initial prepared back stack update can acquire navigation ownership.
122+
// Intentionally don't key on context. attach() only uses it to find where the nav root is
123+
// displayed, which can't change without the lifecycle changing too, and that replaces the
124+
// observer. The context's identity can change more often (e.g., if the host app provides a new
125+
// ContextThemeWrapper on every recomposition), and re-attaching for those changes would
126+
// needlessly finish the nav transaction.
120127
DisposableEffect(observer) {
121-
observer.attach(lifecycle)
128+
observer.attach(NavRootInfo(lifecycle, context))
122129
onDispose { observer.dispose() }
123130
}
124131

‎sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt‎

Lines changed: 226 additions & 87 deletions
Large diffs are not rendered by default.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package io.sentry.compose.navigation3.lifecycle
2+
3+
import android.app.Activity
4+
import android.content.Context
5+
import android.content.ContextWrapper
6+
import androidx.lifecycle.Lifecycle
7+
8+
/**
9+
* Describes the nav root of a `SentryNavEffect`.
10+
*
11+
* A **nav root** is the Activity or Fragment whose content calls a `SentryNavEffect` and owns the
12+
* back stack the effect observes.
13+
*/
14+
internal class NavRootInfo(
15+
/** The nav root's lifecycle. */
16+
val lifecycle: Lifecycle,
17+
/** The context the nav root's content is displayed in, if known. */
18+
val context: Context?,
19+
) {
20+
21+
/**
22+
* The Activity that displays the nav root's content, or `null` if [context] is unknown or doesn't
23+
* belong to an Activity.
24+
*
25+
* If the nav root is itself an Activity, this will be the nav root itself. If the nav root is a
26+
* Fragment, this will be the Activity that hosts that Fragment.
27+
*/
28+
val activity: Activity?
29+
get() = context?.findActivity()
30+
31+
/**
32+
* The Activity the receiver belongs to, found by unwrapping [ContextWrapper]s (e.g., the
33+
* `ContextThemeWrapper` of a dialog), or `null` if there's none.
34+
*/
35+
private fun Context.findActivity(): Activity? {
36+
var context: Context? = this
37+
while (context is ContextWrapper) {
38+
if (context is Activity) {
39+
return context
40+
}
41+
context = context.baseContext
42+
}
43+
return null
44+
}
45+
}

‎sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation"
2424
* Needed because all `SentryNavEffect`s mutate a single [IScope] instance by default and will
2525
* clobber or interleave one another's updates unless properly managed:
2626
* ```
27-
* LifecycleOwner (e.g., Activity or Fragment)
28-
* ↓ owns
27+
* Nav root (Activity or Fragment)
28+
* ↓ calls
2929
* SentryNavEffect
3030
* ↓
3131
* CoordinatedBackStackObserver (wrapper)
@@ -56,7 +56,7 @@ internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation"
5656
* 2. Pass the same [NavLeaseCoordinator] instance to all `NavScopes` whose effects must be
5757
* coordinated (i.e., that target the same `IScope`).
5858
*
59-
* 3. Call [attach] with the lifecycle that manages the effect before the first back stack update.
59+
* 3. Call [attach] with the effect's [nav root][NavRootInfo] before the first back stack update.
6060
*
6161
* 4. Call [onBackStackUpdate] before applying each back stack update.
6262
*
@@ -86,16 +86,18 @@ internal class NavScopes(
8686
private val navStatePublisher = NavStatePublisher()
8787

8888
/**
89-
* Registers this [NavScopes] instance with [lifecycle]. No-ops if `lifecycle` has already been
90-
* attached or is [destroyed][Lifecycle.Event.ON_DESTROY].
89+
* Registers this [NavScopes] instance with the lifecycle of the effect's nav root, as described
90+
* by [rootInfo]. No-ops if that lifecycle has already been attached or is
91+
* [destroyed][Lifecycle.Event.ON_DESTROY].
9192
*
9293
* Attaching to a second, different lifecycle no-ops.
9394
*/
94-
fun attach(lifecycle: Lifecycle) {
95+
fun attach(rootInfo: NavRootInfo) {
9596
val currentLifecycle = attachedLifecycle
97+
val incomingLifecycle = rootInfo.lifecycle
9698

9799
if (currentLifecycle != null) {
98-
if (currentLifecycle !== lifecycle) {
100+
if (currentLifecycle !== incomingLifecycle) {
99101
delegate.options.logger.log(
100102
SentryLevel.WARNING,
101103
"NavScopes is already attached to a different lifecycle; ignoring the new lifecycle.",
@@ -106,12 +108,12 @@ internal class NavScopes(
106108
return
107109
}
108110

109-
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
111+
if (incomingLifecycle.currentState == Lifecycle.State.DESTROYED) {
110112
return
111113
}
112114

113-
attachedLifecycle = lifecycle
114-
registration = coordinator.register(lifecycle, delegate, navStatePublisher)
115+
attachedLifecycle = incomingLifecycle
116+
registration = coordinator.register(delegate, rootInfo, navStatePublisher)
115117
}
116118

117119
/**
@@ -147,7 +149,7 @@ internal class NavScopes(
147149
* Publishes this instance's [stagedNavTransaction] and [stagedNavData] to the shared [IScope]
148150
* while it holds the nav lease, as directed by the [coordinator].
149151
*/
150-
private inner class NavStatePublisher : LeasedNavState {
152+
private inner class NavStatePublisher : INavStatePublisher {
151153

152154
override fun publishTo(scope: IScope) {
153155
// Publishing the data first ensures a staged transaction is sampled against the rotated

‎sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ Support for multiple `SentryNavEffect`s is restricted to the scenarios documente
1717
[Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups).
1818

1919
`NavLeaseCoordinator` implements the policy that underlies those scenarios. It assumes that, at any moment, the user is interacting with at
20-
most one registered `LifecycleOwner` in the host app, and that the `LifecycleOwner` being interacted with is the one that was most recently
21-
resumed.
20+
most one nav root in the host app (see `NavRootInfo` for a definition of "nav root"), and that the nav root being interacted with is the one
21+
that was most recently resumed.
2222

2323
Concretely, a host app can safely use more than one `SentryNavEffect` only if:
2424

‎sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import io.sentry.TransactionOptions
2121
import io.sentry.compose.navigation3.ArgumentDropReason.Companion.ARGUMENT_DROP_REASON_KEY
2222
import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY
2323
import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator
24+
import io.sentry.compose.navigation3.lifecycle.NavRootInfo
2425
import io.sentry.compose.navigation3.lifecycle.NavScopes
2526
import io.sentry.protocol.App
2627
import io.sentry.protocol.TransactionNameSource
@@ -119,7 +120,7 @@ class BackStackObserverTest {
119120
LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED }
120121
}
121122
return NavScopes(scopes, NavLeaseCoordinator()).apply {
122-
attach(owner.lifecycle)
123+
attach(NavRootInfo(owner.lifecycle, context = null))
123124
// The sole resumed owner's first update acquires the lease, which it then keeps.
124125
onBackStackUpdate()
125126
}

‎sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserverTest.kt‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import io.sentry.SentryOptions
1515
import io.sentry.SentryTracer
1616
import io.sentry.TransactionContext
1717
import io.sentry.TransactionOptions
18-
import io.sentry.compose.navigation3.lifecycle.LeasedNavState
18+
import io.sentry.compose.navigation3.lifecycle.INavStatePublisher
1919
import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY
2020
import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator
21+
import io.sentry.compose.navigation3.lifecycle.NavRootInfo
2122
import org.junit.Test
2223
import org.mockito.kotlin.any
2324
import org.mockito.kotlin.doAnswer
@@ -360,7 +361,14 @@ class CoordinatedBackStackObserverTest {
360361
/** Registers another owner that takes the nav lease and then pauses, keeping the lease. */
361362
fun givenPausedLeaseholder() {
362363
val otherLifecycle = resumedLifecycle()
363-
val other = checkNotNull(coordinator.register(otherLifecycle, scopes, NoOpLeasedNavState))
364+
val other =
365+
checkNotNull(
366+
coordinator.register(
367+
scopes,
368+
NavRootInfo(otherLifecycle, context = null),
369+
NoOpNavStatePublisher,
370+
)
371+
)
364372
other.onBackStackUpdate()
365373
otherLifecycle.currentState = Lifecycle.State.STARTED
366374
check(other.hasLease)
@@ -380,7 +388,7 @@ class CoordinatedBackStackObserverTest {
380388
},
381389
coordinator = coordinator,
382390
)
383-
.apply { attach(lifecycle) }
391+
.apply { attach(NavRootInfo(lifecycle, context = null)) }
384392

385393
/**
386394
* Owners of the lifecycles created by [resumedLifecycle]. `LifecycleRegistry` holds its owner
@@ -398,7 +406,7 @@ class CoordinatedBackStackObserverTest {
398406
}
399407

400408
/** Nav state for a competing owner that has nothing to publish. */
401-
private object NoOpLeasedNavState : LeasedNavState {
409+
private object NoOpNavStatePublisher : INavStatePublisher {
402410
override fun publishTo(scope: IScope) = Unit
403411

404412
override fun unpublishFrom(scope: IScope) = Unit

0 commit comments

Comments
 (0)