Skip to content

Commit cb431e3

Browse files
committed
feat(android): Support Nav3 for multi-Activity and multi-Fragment applications (JAVA-274)
Introduce Nav3 support for multi-Activity and multi-Fragment applications by managing shared SentryNavEffect state so that overlapping instances don't interfere with one another. See the [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups) for the full list of supported and unsupported setups. The commit's basic approach was to replace the IScopes instance that BackStackObserver already talks to with a a smarter version that can toggle between the shared underlying scope and a per-instance staged version, depending on whether the observer is associated with the incoming our outgoing SentryNavEffect. Doing so let's us keep modifications to SentryNavEffect, BackStackObserver, etc. to a minimum, and makes it easy to swap out our implementation in the future, should we eventually hide our IScopes API behind a LocalSentrySpan-esque system (or any of a number of other possibilities).
1 parent e5c61b8 commit cb431e3

38 files changed

Lines changed: 5305 additions & 152 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` ([#6229](https://github.com/getsentry/sentry-java/pull/6229))
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
- 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))

‎gradle/libs.versions.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" }
107107
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.3.5" }
108108
androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidxLifecycle" }
109109
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" }
110+
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version = "2.8.3" }
110111
androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" }
111112
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" }
112113
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidxNavigation" }
113114
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" }
114115
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3" }
116+
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version = "1.1.0" }
115117
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" }
116118
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" }
117119
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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,15 @@ dependencies {
6262
implementation(projects.sentry)
6363

6464
compileOnly(libs.androidx.compose.runtime)
65+
// Provides androidx.lifecycle.compose.LocalLifecycleOwner. Hosts get it at runtime through
66+
// Compose UI 1.7+ and navigation3-ui, both of which depend on it.
67+
compileOnly(libs.androidx.lifecycle.runtime.compose)
6568

6669
testImplementation(libs.androidx.compose.ui.test.junit4)
70+
testImplementation(libs.androidx.fragment.ktx)
6771
testImplementation(libs.androidx.test.core)
6872
testImplementation(libs.androidx.test.ext.junit)
73+
testImplementation(libs.androidx.viewpager2)
6974
testImplementation(libs.google.truth)
7075
testImplementation(libs.mockito.inline)
7176
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)