Skip to content

Commit e2a270b

Browse files
authored
feat(android): Nav3 support for multi-Activity and multi-Fragment applications (JAVA-785) (#6301)
Introduces Nav3 support for app architectures involving multiple NavDisplay <> SentryNavEffect pairs, subject to certain limitations (see the "Still not supported" list below). Prior to this commit, we assumed only one NavDisplay <> SentryNavEffect pair would be active at a time. Supported ----------- - A single-Activity app with one NavDisplay <> SentryNavEffect pair. - A multi-Activity app where each Activity has its own NavDisplay <> SentryNavEffect pair. - A multi-Fragment app where each Fragment has its own NavDisplay <> SentryNavEffect pair. - A ViewPager2 whose pages are Fragments, each with its own NavDisplay <> SentryNavEffect pair. - An Activity or Fragment with its own NavDisplay that stays visible beneath a DialogFragment or translucent Activity with its own NavDisplay. Before this PR stack, we only supported (1) and its functional equivalents. Still not supported ------------------ - Nested NavDisplay <> SentryNavEffect pairs in the same Activity or Fragment. - Side-by-side NavDisplay <> SentryNavEffect pairs in the same Activity or Fragment. - SentryNavEffect instances in multiple Activities or Fragments that users can interact with at the same time, such as two Activities from the same app in multi-resume mode. - SentryNavEffect instances in pages or tabs that share their host's lifecycle, such as Compose HorizontalPager pages, ViewPager2 pages that aren't Fragments, or tabs kept in the composition at once. (Those pages or tabs share one LifecycleOwner, so at most one can call SentryNavEffect.) The basic approach ------------------- Supporting the new setups listed above requires coordinating multiple, simultaneously active SentryNavEffects and their updates to the current (mutable + shared) IScope vended by IScopes. Like all forms of state management around Android lifecycles + shared mutable state, that code isn't exactly simple. To keep things manageable (and easy to update), we were careful to sequester all coordination logic to a new .navigation3.lifecycle package. The basic idea was to replace the IScopes instance our BackStackObserver already talked to with a smarter version (NavScopes) that could toggle between the shared underlying scope and a per-instance staged version, depending on whether the observer was associated with the incoming or outgoing SentryNavEffect. (Under the hood, NavScopes relies on lease-based ownership to decide who can update the scope, which is overseen by the new NavLeaseCoordinator.) That^^ let us keep updates to SentryNavEffect, BackStackObserver, etc. to a minimum, and it makes it easy to swap out our implementation in the future, should we ever hide our IScopes API behind a LocalSentrySpan-esque system (or any of a number of other possibilities).
1 parent 6d9e471 commit e2a270b

37 files changed

Lines changed: 5405 additions & 161 deletions

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## Unreleased
44

5+
### Improvements
6+
7+
- Support Navigation 3 integration for multi-Activity and multi-Fragment applications, where each Activity or Fragment has its own `SentryNavEffect` ([#6305](https://github.com/getsentry/sentry-java/pull/6305))
8+
- 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.
9+
510
### Fixes
611

712
- Always remove the first-draw listener after the first frame, even if no layout pass follows it ([#6152](https://github.com/getsentry/sentry-java/pull/6152))

‎gradle/libs.versions.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" }
111111
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.3.5" }
112112
androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidxLifecycle" }
113113
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" }
114+
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version = "2.8.3" }
114115
androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" }
115116
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" }
116117
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidxNavigation" }
117118
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" }
118119
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3" }
120+
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version = "1.1.0" }
119121
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" }
120122
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" }
121123
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" }

‎sentry-android-navigation3/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ Because Nav3 back stacks can have arbitrary key types, host apps need to provide
99
keys and the data Sentry should display for each (see `BackStackEntryMapper`).
1010

1111
See the [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/) for additional details, including
12-
installation, configuration, and limitations.
12+
configuration, supported setups, and limitations.

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,15 @@ kotlin { explicitApi() }
6060

6161
dependencies {
6262
implementation(projects.sentry)
63+
implementation(libs.androidx.lifecycle.runtime.compose)
6364

6465
compileOnly(libs.androidx.compose.runtime)
6566

6667
testImplementation(libs.androidx.compose.ui.test.junit4)
68+
testImplementation(libs.androidx.fragment.ktx)
6769
testImplementation(libs.androidx.test.core)
6870
testImplementation(libs.androidx.test.ext.junit)
71+
testImplementation(libs.androidx.viewpager2)
6972
testImplementation(libs.google.truth)
7073
testImplementation(libs.mockito.core)
7174
testImplementation(libs.mockito.kotlin)

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,9 @@ internal data class NormalizedSentryBackStackEntry(
395395
val name: String,
396396
/** Sanitized [SentryBackStackEntry.arguments] (i.e., bounded in size and depth). */
397397
val arguments: Map<String, Any?> = emptyMap(),
398-
/** The reason why the SDK dropped host-provided arguments. */
398+
/**
399+
* The reason why the SDK dropped host-provided arguments, or null if no arguments were dropped.
400+
*/
399401
val argumentDropReason: ArgumentDropReason? = null,
400402
) {
401403

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

Lines changed: 46 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,21 @@ package io.sentry.compose.navigation3
22

33
import io.sentry.Breadcrumb
44
import io.sentry.Hint
5-
import io.sentry.IScope
6-
import io.sentry.IScopes
75
import io.sentry.ITransaction
8-
import io.sentry.PropagationContext
96
import io.sentry.SentryIntegrationPackageStorage
107
import io.sentry.SentryLevel.DEBUG
118
import io.sentry.SentryLevel.INFO
9+
import io.sentry.SentryOptions
1210
import io.sentry.SpanStatus
1311
import io.sentry.TransactionContext
1412
import io.sentry.TransactionOptions
1513
import io.sentry.compose.navigation3.BackStackConverter.RetentionPolicy
1614
import io.sentry.compose.navigation3.PreparedChange.BackStackHasNewTop
1715
import io.sentry.compose.navigation3.PreparedChange.BackStackHasSameTop
1816
import io.sentry.compose.navigation3.PreparedChange.BackStackIsEmpty
17+
import io.sentry.compose.navigation3.lifecycle.INavScope
18+
import io.sentry.compose.navigation3.lifecycle.INavScopes
19+
import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY
1920
import io.sentry.protocol.App
2021
import io.sentry.protocol.TransactionNameSource
2122
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
@@ -47,10 +48,11 @@ private const val NAVIGATION_OP: String = "navigation"
4748
* **Thread safety**
4849
*
4950
* This class is ***not*** thread-safe. Clients should serialize calls to [onBackStackChanged] and
50-
* [cleanup] (e.g., via invocation from an `*Effect` or another form of thread confinement).
51+
* [dispose] (e.g., via invocation from an `*Effect` or another form of thread confinement).
5152
*/
53+
@Suppress("TooManyFunctions")
5254
internal class BackStackObserver<T : Any>(
53-
private val scopes: IScopes,
55+
private val scopes: INavScopes,
5456
private val options: SentryNavOptions,
5557
entryMapper: ForwardingBackStackEntryMapper<T>,
5658
) {
@@ -110,23 +112,14 @@ internal class BackStackObserver<T : Any>(
110112
scopes.configureScope { scope -> applyChange(scope, change) }
111113
}
112114

113-
internal fun cleanup() {
114-
previousTop = null
115-
previousTopNormalized = null
116-
117-
scopes.configureScope { scope ->
118-
navTransaction.stop(scope)
119-
navScreen.clear(scope)
120-
121-
if (options.captureBackStack) {
122-
// This observer owns the nav context while it's in the composition, and cleanup removes
123-
// it to avoid leaking stale back stack data after observation stops. If the host app
124-
// replaces one observer with another, there may be a brief gap where events lack nav
125-
// context. Apps should keep the observer at the nav root so cleanup only runs when the
126-
// navigation session is ending, not during normal destination changes.
127-
navContext.clear(scope)
128-
}
129-
}
115+
/**
116+
* Clears the nav data this observer recorded.
117+
*
118+
* Call it when observation ends so that no stale screen, nav context, or nav transaction outlives
119+
* it.
120+
*/
121+
internal fun dispose() {
122+
scopes.configureScope { scope -> tearDown(scope) }
130123
}
131124

132125
private fun prepareChange(backStack: List<T>): PreparedChange<T> {
@@ -140,7 +133,7 @@ internal class BackStackObserver<T : Any>(
140133
}
141134
}
142135

143-
private fun applyChange(scope: IScope, change: PreparedChange<T>) {
136+
private fun applyChange(scope: INavScope, change: PreparedChange<T>) {
144137
when (change) {
145138
is BackStackIsEmpty -> handleEmptyBackStack(scope)
146139
is BackStackHasNewTop -> handleNewTop(scope, change.previousTop, change.backStack)
@@ -185,7 +178,7 @@ internal class BackStackObserver<T : Any>(
185178
}
186179

187180
private fun handleNewTop(
188-
scope: IScope,
181+
scope: INavScope,
189182
previousTop: NormalizedSentryBackStackEntry?,
190183
currentBackStack: BackStackData<T>,
191184
) {
@@ -212,23 +205,19 @@ internal class BackStackObserver<T : Any>(
212205
)
213206
?.let { transaction -> navContext.updateTransaction(transaction, scope, currentBackStack) }
214207
} else {
215-
// Rotate the propagation context.
216-
scope.withPropagationContext { scope.setPropagationContext(PropagationContext()) }
208+
scope.rotatePropagationContext()
217209
}
218210

219211
storeAsPreviousTop(currentBackStack.topEntry, currentBackStack.topEntryNormalized)
220212
}
221213

222-
private fun handleSameTop(scope: IScope, backStack: BackStackData<T>) {
214+
private fun handleSameTop(scope: INavScope, backStack: BackStackData<T>) {
223215
navContext.update(scope, backStack.capturedEntriesNormalized)
224216
storeAsPreviousTop(backStack.topEntry, backStack.topEntryNormalized)
225217
}
226218

227-
private fun handleEmptyBackStack(scope: IScope) {
228-
navTransaction.stop(scope)
229-
navContext.clear(scope)
230-
navScreen.clear(scope)
231-
clearPreviousTop()
219+
private fun handleEmptyBackStack(scope: INavScope) {
220+
tearDown(scope)
232221
}
233222

234223
private fun storeAsPreviousTop(topEntry: T, topEntryNormalized: NormalizedSentryBackStackEntry) {
@@ -240,12 +229,19 @@ internal class BackStackObserver<T : Any>(
240229
previousTop = null
241230
previousTopNormalized = null
242231
}
232+
233+
private fun tearDown(scope: INavScope) {
234+
navTransaction.stop(scope)
235+
navContext.clear(scope)
236+
navScreen.clear(scope)
237+
clearPreviousTop()
238+
}
243239
}
244240

245241
/**
246242
* A model for applying one back stack update.
247243
*
248-
* Lets us separate change preparation from its application so that the [IScopes.configureScope]
244+
* Lets us separate change preparation from its application so that the [INavScopes.configureScope]
249245
* callback in charge of application can use already-computed navigation state. Otherwise, any
250246
* exceptions thrown during state computation would be swallowed by `configureScope`'s over-broad
251247
* `catch` clause.
@@ -281,7 +277,7 @@ private data class BackStackData<T>(
281277
)
282278

283279
/** A helper class for managing nav transactions. */
284-
private class NavTransaction(private val scopes: IScopes) {
280+
private class NavTransaction(private val scopes: INavScopes) {
285281

286282
private companion object {
287283
private const val TRANSACTION_ORIGIN = "auto.navigation.nav3"
@@ -291,11 +287,11 @@ private class NavTransaction(private val scopes: IScopes) {
291287

292288
/** Starts an idle navigation transaction, or no-ops if another transaction is already active. */
293289
fun start(
294-
scope: IScope,
290+
scope: INavScope,
295291
name: String,
296292
arguments: Map<String, Any?>,
297293
): ITransaction? {
298-
clearTransactionIfFinished(scope)
294+
scope.clearFinishedTransaction()
299295

300296
if (scope.transaction != null) {
301297
scopes.options.logger.log(
@@ -323,6 +319,7 @@ private class NavTransaction(private val scopes: IScopes) {
323319
transactionOptions,
324320
)
325321

322+
// Not an error: INavScopes.startTransaction may decline to start a transaction by design.
326323
if (transaction.isNoOp) {
327324
return null
328325
}
@@ -335,61 +332,39 @@ private class NavTransaction(private val scopes: IScopes) {
335332
}
336333
}
337334

338-
scope.withTransaction { tx ->
339-
if (tx == null) {
340-
scope.transaction = transaction
341-
}
342-
}
335+
scope.bindTransaction(transaction)
343336

344337
return transaction
345338
}
346339

347340
/** Finishes and unsets the active navigation transaction, if one exists. */
348-
fun stop(scope: IScope) {
341+
fun stop(scope: INavScope) {
349342
val transaction = activeNavTransaction ?: return
350343
val status = transaction.status ?: SpanStatus.OK
351344
transaction.finish(status)
352-
353-
scope.withTransaction { tx ->
354-
if (tx == transaction) {
355-
scope.clearTransaction()
356-
}
357-
}
358-
345+
scope.unbindTransaction(transaction)
359346
activeNavTransaction = null
360347
}
361-
362-
/** Clears a stale finished transaction that's still bound to the default scope. */
363-
private fun clearTransactionIfFinished(scope: IScope) {
364-
scope.withTransaction { tx ->
365-
if (tx?.isFinished == true) {
366-
scope.clearTransaction()
367-
}
368-
}
369-
}
370348
}
371349

372350
/** A helper class for updating [nav context][NAVIGATION_CONTEXT_KEY]. */
373-
private class NavContext(private val scopes: IScopes, private val options: SentryNavOptions) {
351+
private class NavContext(private val scopes: INavScopes, private val options: SentryNavOptions) {
374352

375353
private companion object {
376354
private const val BACKSTACK_KEY = "Back Stack"
377-
private const val NAVIGATION_CONTEXT_KEY = "Navigation"
378355
}
379356

380-
fun update(scope: IScope, backStackEntries: List<NormalizedSentryBackStackEntry>) {
357+
fun update(scope: INavScope, backStackEntries: List<NormalizedSentryBackStackEntry>) {
381358
if (backStackEntries.isEmpty()) {
382359
clear(scope)
383360
return
384361
}
385362

386-
scope.setContexts(NAVIGATION_CONTEXT_KEY, backStackEntries.toBackStackMap())
363+
scope.setNavigationContext(backStackEntries.toBackStackMap())
387364
}
388365

389-
fun clear(scope: IScope) {
390-
// We purposefully don't call IScope.removeContexts(), as it doesn't notify IScopeObserver and
391-
// therefore doesn't write its updates to disk ¯\_ (ツ)_/¯.
392-
scope.setContexts(NAVIGATION_CONTEXT_KEY, null as Any?)
366+
fun clear(scope: INavScope) {
367+
scope.setNavigationContext(null)
393368
}
394369

395370
/**
@@ -403,12 +378,11 @@ private class NavContext(private val scopes: IScopes, private val options: Sentr
403378
*/
404379
fun <T : Any> updateTransaction(
405380
transaction: ITransaction,
406-
scope: IScope,
381+
scope: INavScope,
407382
backStack: BackStackData<T>,
408383
) {
409384
if (scopes.options.isEnableScreenTracking) {
410-
val appContext =
411-
transaction.contexts.app ?: io.sentry.protocol.Contexts(scope.contexts).app ?: App()
385+
val appContext = transaction.contexts.app ?: scope.app ?: App()
412386

413387
appContext.viewNames = listOf(backStack.topEntryNormalized.name)
414388
transaction.contexts.setApp(appContext)
@@ -432,12 +406,12 @@ private class NavScreen {
432406

433407
private var lastScreenName: String? = null
434408

435-
fun update(scope: IScope, currentEntry: NormalizedSentryBackStackEntry) {
409+
fun update(scope: INavScope, currentEntry: NormalizedSentryBackStackEntry) {
436410
scope.screen = currentEntry.name
437411
lastScreenName = currentEntry.name
438412
}
439413

440-
fun clear(scope: IScope) {
414+
fun clear(scope: INavScope) {
441415
val screenName = lastScreenName ?: return
442416
if (scope.screen == screenName) {
443417
scope.screen = null
@@ -447,7 +421,7 @@ private class NavScreen {
447421
}
448422

449423
/** A helper class for generating nav breadcrumbs. */
450-
private class NavBreadcrumbs(private val scopes: IScopes) {
424+
private class NavBreadcrumbs(private val scopes: INavScopes) {
451425

452426
fun emit(fromEntry: NormalizedSentryBackStackEntry?, toEntry: NormalizedSentryBackStackEntry) {
453427
val breadcrumb =
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package io.sentry.compose.navigation3
2+
3+
import androidx.lifecycle.Lifecycle
4+
import androidx.lifecycle.LifecycleEventObserver
5+
import io.sentry.IScope
6+
import io.sentry.IScopes
7+
import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator
8+
import io.sentry.compose.navigation3.lifecycle.NavScopes
9+
10+
/**
11+
* A [BackStackObserver] whose updates to a shared mutable [IScope] are coordinated with other
12+
* `BackStackObserver`s.
13+
*
14+
* Coordination is achieved via [NavScopes].
15+
*
16+
* **Usage**
17+
*
18+
* Call [attach] before the first [onBackStackChanged], and call [dispose] when observation ends.
19+
*
20+
* Instances targeting the same [IScope] must be created with the same [NavLeaseCoordinator].
21+
*
22+
* **Thread safety**
23+
*
24+
* This class is ***not*** thread-safe. Invocations should be confined to the same thread as the
25+
* [LifecycleEventObserver] callbacks implemented by [NavScopes].
26+
*/
27+
internal class CoordinatedBackStackObserver<T : Any>(
28+
scopes: IScopes,
29+
options: SentryNavOptions,
30+
entryMapper: ForwardingBackStackEntryMapper<T>,
31+
coordinator: NavLeaseCoordinator,
32+
) {
33+
34+
private val navScopes = NavScopes(scopes, coordinator)
35+
private val observer = BackStackObserver(navScopes, options, entryMapper)
36+
37+
fun attach(lifecycle: Lifecycle) {
38+
navScopes.attach(lifecycle)
39+
}
40+
41+
fun onBackStackChanged(backStack: List<T>) {
42+
navScopes.onBackStackUpdate()
43+
observer.onBackStackChanged(backStack)
44+
}
45+
46+
fun dispose() {
47+
// Call disposal on both the observer and navScopes in case their (currently overlapping)
48+
// implementations drift over time.
49+
observer.dispose()
50+
navScopes.dispose()
51+
}
52+
}
53+
54+
/** The [NavLeaseCoordinator] shared by default by all [SentryNavEffect]s. */
55+
internal val defaultNavLeaseCoordinator: NavLeaseCoordinator = NavLeaseCoordinator()

0 commit comments

Comments
 (0)