Skip to content

Commit 6d0acbc

Browse files
committed
ref(android): Support Nav3 for multi-Activity applications (JAVA-274)
Introduce Nav3 support for multi-Activity applications by managing shared SentryNavEffect state so that multiple instances can overlap during Activity transitions without interfering with one another. 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 3cf91ba commit 6d0acbc

34 files changed

Lines changed: 4238 additions & 162 deletions

‎CHANGELOG.md‎

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

33
## Unreleased
44

5+
### Improvements
6+
7+
- Support Navigation 3 integration for multi-Activity applications, where each Activity has its own `SentryNavEffect` ([#6229](https://github.com/getsentry/sentry-java/pull/6229))
8+
- Each `LifecycleOwner` (e.g., an Activity or Fragment) can have its own `SentryNavEffect`, as long as the user can't interact with more than one of those owners at a time. Owners that stay visible beneath the one the user interacts with (e.g., beneath a dialog or translucent Activity) are supported
9+
- Limitations: Does not support nested or side-by-side `NavDisplay` and `SentryNavEffect` pairs within the same `LifecycleOwner`, or `SentryNavEffect`s in multiple owners the user can interact with at the same time (e.g., two Activities in [multi-resume mode](https://developer.android.com/develop/ui/views/layout/support-multi-window-mode))
10+
511
### Fixes
612

713
- Measure Session Replay's 1h recording cap and its touch-move debounce on a monotonic clock, so a device time change no longer stops a healthy recording early, keeps a finished one alive, or suppresses gesture events ([#6090](https://github.com/getsentry/sentry-java/pull/6090))

‎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/build.gradle.kts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ 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)
6770
testImplementation(libs.androidx.test.core)

‎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: 30 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,27 @@ 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
1919
import io.sentry.protocol.App
2020
import io.sentry.protocol.TransactionNameSource
2121
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
2222
import java.lang.ref.WeakReference
2323

24+
internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation"
25+
2426
private const val NAVIGATION_OP: String = "navigation"
2527

2628
/**
@@ -46,11 +48,11 @@ private const val NAVIGATION_OP: String = "navigation"
4648
*
4749
* **Thread safety**
4850
*
49-
* 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+
* This class is ***not*** thread-safe. Clients should serialize calls to [onBackStackChanged]
52+
* (e.g., via invocation from an `*Effect` or another form of thread confinement).
5153
*/
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,25 +112,6 @@ 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-
}
130-
}
131-
132115
private fun prepareChange(backStack: List<T>): PreparedChange<T> {
133116
val topEntry = backStack.lastOrNull() ?: return BackStackIsEmpty
134117
val data = backStack.extractData()
@@ -140,7 +123,7 @@ internal class BackStackObserver<T : Any>(
140123
}
141124
}
142125

143-
private fun applyChange(scope: IScope, change: PreparedChange<T>) {
126+
private fun applyChange(scope: INavScope, change: PreparedChange<T>) {
144127
when (change) {
145128
is BackStackIsEmpty -> handleEmptyBackStack(scope)
146129
is BackStackHasNewTop -> handleNewTop(scope, change.previousTop, change.backStack)
@@ -185,7 +168,7 @@ internal class BackStackObserver<T : Any>(
185168
}
186169

187170
private fun handleNewTop(
188-
scope: IScope,
171+
scope: INavScope,
189172
previousTop: NormalizedSentryBackStackEntry?,
190173
currentBackStack: BackStackData<T>,
191174
) {
@@ -212,19 +195,18 @@ internal class BackStackObserver<T : Any>(
212195
)
213196
?.let { transaction -> navContext.updateTransaction(transaction, scope, currentBackStack) }
214197
} else {
215-
// Rotate the propagation context.
216-
scope.withPropagationContext { scope.setPropagationContext(PropagationContext()) }
198+
scope.rotatePropagationContext()
217199
}
218200

219201
storeAsPreviousTop(currentBackStack.topEntry, currentBackStack.topEntryNormalized)
220202
}
221203

222-
private fun handleSameTop(scope: IScope, backStack: BackStackData<T>) {
204+
private fun handleSameTop(scope: INavScope, backStack: BackStackData<T>) {
223205
navContext.update(scope, backStack.capturedEntriesNormalized)
224206
storeAsPreviousTop(backStack.topEntry, backStack.topEntryNormalized)
225207
}
226208

227-
private fun handleEmptyBackStack(scope: IScope) {
209+
private fun handleEmptyBackStack(scope: INavScope) {
228210
navTransaction.stop(scope)
229211
navContext.clear(scope)
230212
navScreen.clear(scope)
@@ -245,7 +227,7 @@ internal class BackStackObserver<T : Any>(
245227
/**
246228
* A model for applying one back stack update.
247229
*
248-
* Lets us separate change preparation from its application so that the [IScopes.configureScope]
230+
* Lets us separate change preparation from its application so that the [INavScopes.configureScope]
249231
* callback in charge of application can use already-computed navigation state. Otherwise, any
250232
* exceptions thrown during state computation would be swallowed by `configureScope`'s over-broad
251233
* `catch` clause.
@@ -281,7 +263,7 @@ private data class BackStackData<T>(
281263
)
282264

283265
/** A helper class for managing nav transactions. */
284-
private class NavTransaction(private val scopes: IScopes) {
266+
private class NavTransaction(private val scopes: INavScopes) {
285267

286268
private companion object {
287269
private const val TRANSACTION_ORIGIN = "auto.navigation.nav3"
@@ -291,11 +273,11 @@ private class NavTransaction(private val scopes: IScopes) {
291273

292274
/** Starts an idle navigation transaction, or no-ops if another transaction is already active. */
293275
fun start(
294-
scope: IScope,
276+
scope: INavScope,
295277
name: String,
296278
arguments: Map<String, Any?>,
297279
): ITransaction? {
298-
clearTransactionIfFinished(scope)
280+
scope.clearFinishedTransaction()
299281

300282
if (scope.transaction != null) {
301283
scopes.options.logger.log(
@@ -335,61 +317,39 @@ private class NavTransaction(private val scopes: IScopes) {
335317
}
336318
}
337319

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

344322
return transaction
345323
}
346324

347325
/** Finishes and unsets the active navigation transaction, if one exists. */
348-
fun stop(scope: IScope) {
326+
fun stop(scope: INavScope) {
349327
val transaction = activeNavTransaction ?: return
350328
val status = transaction.status ?: SpanStatus.OK
351329
transaction.finish(status)
352-
353-
scope.withTransaction { tx ->
354-
if (tx == transaction) {
355-
scope.clearTransaction()
356-
}
357-
}
358-
330+
scope.unbindTransaction(transaction)
359331
activeNavTransaction = null
360332
}
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-
}
370333
}
371334

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

375338
private companion object {
376339
private const val BACKSTACK_KEY = "Back Stack"
377-
private const val NAVIGATION_CONTEXT_KEY = "Navigation"
378340
}
379341

380-
fun update(scope: IScope, backStackEntries: List<NormalizedSentryBackStackEntry>) {
342+
fun update(scope: INavScope, backStackEntries: List<NormalizedSentryBackStackEntry>) {
381343
if (backStackEntries.isEmpty()) {
382344
clear(scope)
383345
return
384346
}
385347

386-
scope.setContexts(NAVIGATION_CONTEXT_KEY, backStackEntries.toBackStackMap())
348+
scope.setNavigationContext(backStackEntries.toBackStackMap())
387349
}
388350

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?)
351+
fun clear(scope: INavScope) {
352+
scope.setNavigationContext(null)
393353
}
394354

395355
/**
@@ -403,12 +363,11 @@ private class NavContext(private val scopes: IScopes, private val options: Sentr
403363
*/
404364
fun <T : Any> updateTransaction(
405365
transaction: ITransaction,
406-
scope: IScope,
366+
scope: INavScope,
407367
backStack: BackStackData<T>,
408368
) {
409369
if (scopes.options.isEnableScreenTracking) {
410-
val appContext =
411-
transaction.contexts.app ?: io.sentry.protocol.Contexts(scope.contexts).app ?: App()
370+
val appContext = transaction.contexts.app ?: scope.app ?: App()
412371

413372
appContext.viewNames = listOf(backStack.topEntryNormalized.name)
414373
transaction.contexts.setApp(appContext)
@@ -432,12 +391,12 @@ private class NavScreen {
432391

433392
private var lastScreenName: String? = null
434393

435-
fun update(scope: IScope, currentEntry: NormalizedSentryBackStackEntry) {
394+
fun update(scope: INavScope, currentEntry: NormalizedSentryBackStackEntry) {
436395
scope.screen = currentEntry.name
437396
lastScreenName = currentEntry.name
438397
}
439398

440-
fun clear(scope: IScope) {
399+
fun clear(scope: INavScope) {
441400
val screenName = lastScreenName ?: return
442401
if (scope.screen == screenName) {
443402
scope.screen = null
@@ -447,7 +406,7 @@ private class NavScreen {
447406
}
448407

449408
/** A helper class for generating nav breadcrumbs. */
450-
private class NavBreadcrumbs(private val scopes: IScopes) {
409+
private class NavBreadcrumbs(private val scopes: INavScopes) {
451410

452411
fun emit(fromEntry: NormalizedSentryBackStackEntry?, toEntry: NormalizedSentryBackStackEntry) {
453412
val breadcrumb =

0 commit comments

Comments
 (0)