Skip to content

Commit a21cd09

Browse files
authored
fix(android): Nav3 support for ViewPager2 (JAVA-785) (#6398)
Make the multi-Activity and multi-Fragment support introduced by the #6301 PR stack play nicely with ViewPager2. BACKGROUND The approach in #6301 was smart about staging nav data (ie, screen name + nav context), but it didn't do the same for nav transactions. A new nav transaction could be started only if the lease was owned at the time of navigation, where "navigation" == initialization or modification of the nav back stack. Because the lease had to be owned, that meant that the Activity or Fragment hosting the SentryNavEffect requesting the lease had to be in a resumed state. That^^ assumption made sense for most use cases, because usually initialization of an Activity or Fragment's nav back stack coincides with the Activity or Fragment being visible and therefore being able to preempt the lease from the previous leaseholder. But the assumption didn't pan out for ViewPager2, because ViewPager2 preloads neighboring pages. That means it initializes the nav back stack for Fragments that aren't in a resumed state and therefore can't preempt the lease, causing them to drop their initial nav transaction and any spans created by the initial destination screen. Sigh. THE SOLUTION The solution this commit implements is to create a mechanism similar to the data staging from #6301, but for transactions. Doing so lets us separate back stack initialization observed during preloading vs attempted lease acquisition when a ViewPager2 page becomes visible. In particular, the new StagedNavTransaction class tracks requests to start nav transactions when a SentryNavEffect is created even if it doesn't currently have the lease. If the lease is later acquired and the transaction slot is available on the shared IScope, the staged transaction is started, which keeps ViewPager2 happy. REFACTORING INCLUDED Included in this commit is a refactor of code in the .navigation3.lifecycle package. The refactor lets us separate three responsibilities: - Lease coordination: NavLeaseCoordinator observes registered owners’ lifecycles and decides which SentryNavEffect may publish navigation state. - State publication: Each effect’s NavScopes publishes and withdraws its navigation data and transaction together when ownership changes. - Transaction management: Each effect gets a transaction handle that manages starting, finishing, binding, and restoring its transaction. The coordinator no longer retains transactions itself.
1 parent ff7e433 commit a21cd09

23 files changed

Lines changed: 3780 additions & 1480 deletions

‎CHANGELOG.md‎

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

1717
### Fixes
1818

19+
- 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))
1920
- 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))
2021
- 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.
2122
- Refuse to send data to servers whose certificate was issued by one of the leaked eMudhra intermediates (CVE-2026-111943) ([#6336](https://github.com/getsentry/sentry-java/pull/6336))

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ internal data class NormalizedSentryBackStackEntry(
450450
internal fun List<NormalizedSentryBackStackEntry>.serialize(): List<Map<String, Any?>> =
451451
map(NormalizedSentryBackStackEntry::serialize)
452452

453-
/** Why host-provided arguments were unavailable in emitted navigation data. */
453+
/** Why host-provided arguments were unavailable in emitted nav state. */
454454
internal enum class ArgumentDropReason(
455455
val serializedValue: String,
456456
val exhaustsUpdateBudget: Boolean = false,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ public class SentryBackStackEntry(
7878
*
7979
* If [map] throws, returns `null`, or returns a blank [name][SentryBackStackEntry.name], Sentry
8080
* records the destination as "/unknown". Doing so signals that name extraction needs to be fixed
81-
* while avoiding misleading gaps in navigation data.
81+
* while avoiding misleading gaps in nav state.
8282
*
8383
* For instance, if a user navigates from `/home -> /detail -> /settings`, but the mapper for
8484
* `/detail` throws, the back stack record will be `/home -> /unknown -> /settings` rather than

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

Lines changed: 27 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,9 @@ package io.sentry.compose.navigation3
22

33
import io.sentry.Breadcrumb
44
import io.sentry.Hint
5-
import io.sentry.ITransaction
65
import io.sentry.SentryIntegrationPackageStorage
7-
import io.sentry.SentryLevel.DEBUG
86
import io.sentry.SentryLevel.INFO
97
import io.sentry.SentryOptions
10-
import io.sentry.SpanStatus
118
import io.sentry.TransactionContext
129
import io.sentry.TransactionOptions
1310
import io.sentry.compose.navigation3.BackStackConverter.RetentionPolicy
@@ -16,6 +13,7 @@ import io.sentry.compose.navigation3.PreparedChange.BackStackHasSameTop
1613
import io.sentry.compose.navigation3.PreparedChange.BackStackIsEmpty
1714
import io.sentry.compose.navigation3.lifecycle.INavScope
1815
import io.sentry.compose.navigation3.lifecycle.INavScopes
16+
import io.sentry.compose.navigation3.lifecycle.INavTransaction
1917
import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY
2018
import io.sentry.protocol.App
2119
import io.sentry.protocol.TransactionNameSource
@@ -59,7 +57,7 @@ internal class BackStackObserver<T : Any>(
5957

6058
private val backStackConverter = BackStackConverter(entryMapper, scopes.options.logger)
6159

62-
private val navTransaction = NavTransaction(scopes)
60+
private val navTransactions = NavTransactions(scopes)
6361
private val navBreadcrumbs = NavBreadcrumbs(scopes)
6462
private val navScreen = NavScreen()
6563
private val navContext = NavContext(scopes, options)
@@ -83,7 +81,7 @@ internal class BackStackObserver<T : Any>(
8381
}
8482

8583
/**
86-
* Updates Sentry nav data based on the provided [backStack].
84+
* Updates Sentry nav state based on the provided [backStack].
8785
*
8886
* **Data generated**
8987
*
@@ -113,7 +111,7 @@ internal class BackStackObserver<T : Any>(
113111
}
114112

115113
/**
116-
* Clears the nav data this observer recorded.
114+
* Clears the nav state this observer recorded.
117115
*
118116
* Call it when observation ends so that no stale screen, nav context, or nav transaction outlives
119117
* it.
@@ -194,10 +192,10 @@ internal class BackStackObserver<T : Any>(
194192
navBreadcrumbs.emit(fromEntry = previousTop, toEntry = currentBackStack.topEntryNormalized)
195193
}
196194

197-
navTransaction.stop(scope)
195+
navTransactions.stop(scope)
198196

199197
if (areNavigationTransactionsEnabled) {
200-
navTransaction
198+
navTransactions
201199
.start(
202200
scope,
203201
currentTop.name,
@@ -231,7 +229,7 @@ internal class BackStackObserver<T : Any>(
231229
}
232230

233231
private fun tearDown(scope: INavScope) {
234-
navTransaction.stop(scope)
232+
navTransactions.stop(scope)
235233
navContext.clear(scope)
236234
navScreen.clear(scope)
237235
clearPreviousTop()
@@ -277,32 +275,22 @@ private data class BackStackData<T>(
277275
)
278276

279277
/** A helper class for managing nav transactions. */
280-
private class NavTransaction(private val scopes: INavScopes) {
278+
private class NavTransactions(private val scopes: INavScopes) {
281279

282280
private companion object {
283281
private const val TRANSACTION_ORIGIN = "auto.navigation.nav3"
284282
}
285283

286-
private var activeNavTransaction: ITransaction? = null
287-
288-
/** Starts an idle navigation transaction, or no-ops if another transaction is already active. */
284+
/**
285+
* Starts an idle navigation transaction, or no-ops if another transaction is already active.
286+
*
287+
* [Stop][stop] the current navigation transaction first. See [INavScope.startNavTransaction].
288+
*/
289289
fun start(
290290
scope: INavScope,
291291
name: String,
292292
arguments: Map<String, Any?>,
293-
): ITransaction? {
294-
scope.clearFinishedTransaction()
295-
296-
if (scope.transaction != null) {
297-
scopes.options.logger.log(
298-
DEBUG,
299-
"Nav3 transaction for route %s won't be created because another transaction is active.",
300-
name,
301-
)
302-
303-
return null
304-
}
305-
293+
): INavTransaction? {
306294
val transactionOptions =
307295
TransactionOptions().also {
308296
it.isWaitForChildren = true
@@ -314,36 +302,25 @@ private class NavTransaction(private val scopes: INavScopes) {
314302
}
315303

316304
val transaction =
317-
scopes.startTransaction(
305+
scope.startNavTransaction(
318306
TransactionContext(name, TransactionNameSource.ROUTE, NAVIGATION_OP),
319307
transactionOptions,
320-
)
308+
) ?: return null
321309

322-
// Not an error: INavScopes.startTransaction may decline to start a transaction by design.
323-
if (transaction.isNoOp) {
324-
return null
310+
if (arguments.isNotEmpty()) {
311+
transaction.setData("arguments", arguments)
325312
}
326313

327-
activeNavTransaction = transaction
328-
329-
transaction.apply {
330-
if (arguments.isNotEmpty()) {
331-
setData("arguments", arguments)
332-
}
333-
}
334-
335-
scope.bindTransaction(transaction)
336-
337314
return transaction
338315
}
339316

340-
/** Finishes and unsets the active navigation transaction, if one exists. */
317+
/**
318+
* Finishes and unsets the active navigation transaction, if one exists.
319+
*
320+
* See [INavScope.finishNavTransaction].
321+
*/
341322
fun stop(scope: INavScope) {
342-
val transaction = activeNavTransaction ?: return
343-
val status = transaction.status ?: SpanStatus.OK
344-
transaction.finish(status)
345-
scope.unbindTransaction(transaction)
346-
activeNavTransaction = null
323+
scope.finishNavTransaction()
347324
}
348325
}
349326

@@ -377,15 +354,15 @@ private class NavContext(private val scopes: INavScopes, private val options: Se
377354
* proper values to the transaction context instead.
378355
*/
379356
fun <T : Any> updateTransaction(
380-
transaction: ITransaction,
357+
transaction: INavTransaction,
381358
scope: INavScope,
382359
backStack: BackStackData<T>,
383360
) {
384361
if (scopes.options.isEnableScreenTracking) {
385-
val appContext = transaction.contexts.app ?: scope.app ?: App()
362+
val appContext = scope.app ?: App()
386363

387364
appContext.viewNames = listOf(backStack.topEntryNormalized.name)
388-
transaction.contexts.setApp(appContext)
365+
transaction.setApp(appContext)
389366
}
390367

391368
if (options.captureBackStack && backStack.capturedEntriesNormalized.isNotEmpty()) {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import io.sentry.compose.navigation3.lifecycle.NavScopes
2222
* **Thread safety**
2323
*
2424
* This class is ***not*** thread-safe. Invocations should be confined to the same thread as the
25-
* [LifecycleEventObserver] callbacks implemented by [NavScopes].
25+
* [LifecycleEventObserver] callbacks through which the [NavLeaseCoordinator] observes the attached
26+
* lifecycle.
2627
*/
2728
internal class CoordinatedBackStackObserver<T : Any>(
2829
scopes: IScopes,

0 commit comments

Comments
 (0)