Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
### Behavioral Changes

- Nav3: Returning to an Activity or Fragment with its own `SentryNavEffect` within the app (e.g., closing the Activity that covered it, swiping back to its ViewPager2 page, or dismissing a dialog over it) now starts a new navigation transaction for the current destination if the previous one finished, so spans produced upon return can now be consistently captured ([#6482](https://github.com/getsentry/sentry-java/pull/6482))
- Nav3: `ui.load` transactions are bound to `IScope` in preference to Nav3 transactions when transitioning between Activities, matching Nav2 behavior (API 29+) ([#6484](https://github.com/getsentry/sentry-java/pull/6484))

### Fixes

Expand Down
1 change: 1 addition & 0 deletions sentry-android-navigation3/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ dependencies {
compileOnly(libs.androidx.compose.runtime)
compileOnly(libs.androidx.compose.ui)

testImplementation(projects.sentryAndroidCore)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.fragment.ktx)
testImplementation(libs.androidx.test.core)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,59 @@ package io.sentry.compose.navigation3.lifecycle
import android.app.Activity
import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Build
import android.os.Bundle

/**
* Watches every Activity of the host application and reports the Activity lifecycle events that
* [NavLeaseCoordinator] acts on.
* [NavLeaseCoordinator] acts on, so that the coordinator needn't know how they differ across API
* levels.
*
* - [onActivityResumed] is called when any Activity resumes, including Activities without a
* `SentryNavEffect`.
* Each event is reported relative to `ActivityLifecycleIntegration`, which binds an Activity's
* `ui.load` transaction to the scope when the Activity is created and removes it when the Activity
* is destroyed:
*
* - [onActivityCreatedOrResumed] is called when an Activity is created and again when it resumes,
* with [ActivityEvent.CREATED] or [ActivityEvent.RESUMED] as its event. On API 29+, creation is
* reported before `ActivityLifecycleIntegration` tries to bind the Activity's `ui.load`
* transaction; below API 29, after. So below API 29, a withdrawal on creation comes too late for
* that `ui.load` transaction, which stays unbound (see "Non-candidate roots and lease withdrawal"
* in the package README).
*
* - [onActivityDestroyed] is called when an Activity is destroyed, after
* `ActivityLifecycleIntegration` has removed the Activity's `ui.load` transaction from the scope.
*
* **Android ordering assumptions**
*
* The watcher relies on the following callback orders, which Android doesn't document but AOSP has
* historically upheld:
*
* - `Application` calls `ActivityLifecycleCallbacks` in registration order. Below API 29,
* `ActivityLifecycleIntegration`'s callbacks run before this class's, provided the SDK is
* initialized before this class starts [watching][watch].
*
* - On API 29+, every `onActivityPreCreated` callback runs before any `onActivityCreated` callback,
* and every `onActivityDestroyed` callback before any `onActivityPostDestroyed` callback.
*
* **Lifetime**
*
* The callbacks are registered at most once per watcher and never unregistered. Like
* `defaultNavLeaseCoordinator`, which owns the watcher used in practice, they're meant to live as
* long as the process.
*/
internal class NavActivityWatcher(private val onActivityResumed: (activity: Activity) -> Unit) {
internal class NavActivityWatcher(
private val onActivityCreatedOrResumed: (activity: Activity, event: ActivityEvent) -> Unit,
private val onActivityDestroyed: (activity: Activity) -> Unit,
) {

/**
* The Activity lifecycle event passed to [onActivityCreatedOrResumed], described by [description]
* in past tense (e.g., "created") for log messages.
*/
enum class ActivityEvent(val description: String) {
CREATED("created"),
RESUMED("resumed"),
}

private var isWatching = false

Expand All @@ -33,11 +70,23 @@ internal class NavActivityWatcher(private val onActivityResumed: (activity: Acti

private inner class Callbacks : ActivityLifecycleCallbacks {

override fun onActivityResumed(activity: Activity) {
this@NavActivityWatcher.onActivityResumed(activity)
// Only called on API 29+, before any callback's onActivityCreated.
override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) {
onActivityCreatedOrResumed(activity, ActivityEvent.CREATED)
}

// Covers API levels below 29, as they don't call onActivityPreCreated. Runs after
// ActivityLifecycleIntegration's onActivityCreated, which finds the slot taken and never binds
// the Activity's ui.load transaction. This is a known limitation (see the package README).
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
onActivityCreatedOrResumed(activity, ActivityEvent.CREATED)
}
}

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityResumed(activity: Activity) {
onActivityCreatedOrResumed(activity, ActivityEvent.RESUMED)
}

override fun onActivityStarted(activity: Activity) = Unit

Expand All @@ -47,6 +96,16 @@ internal class NavActivityWatcher(private val onActivityResumed: (activity: Acti

override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit

override fun onActivityDestroyed(activity: Activity) = Unit
// Covers API levels below 29, which don't call onActivityPostDestroyed.
override fun onActivityDestroyed(activity: Activity) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
this@NavActivityWatcher.onActivityDestroyed(activity)
}
}

// Only called on API 29+, after every callback's onActivityDestroyed.
override fun onActivityPostDestroyed(activity: Activity) {
this@NavActivityWatcher.onActivityDestroyed(activity)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,14 @@ internal class NavLeaseCoordinator {

/**
* Reports the host application's Activities to this coordinator so that it can detect whether a
* leaseholder is covered by a non-candidate root.
* leaseholder is covered by a non-candidate root, and retry a nav transaction blocked by a closed
* Activity's `ui.load` transaction.
*/
private val activityWatcher = NavActivityWatcher(onActivityResumed = ::onAnyActivityResumed)
private val activityWatcher =
NavActivityWatcher(
onActivityCreatedOrResumed = ::onActivityCreatedOrResumed,
onActivityDestroyed = ::onAnyActivityDestroyed,
)

/**
* Registers a [nav root][rootInfo] as a lease candidate until disposal or lifecycle destruction.
Expand Down Expand Up @@ -307,14 +312,17 @@ internal class NavLeaseCoordinator {
it.canReceiveLease && it.isActivityNavRoot(activity)
}

/** Withdraws the lease when the resumed [activity] appears to cover a non-resumed nav root. */
private fun onAnyActivityResumed(activity: Activity) {
// Assume the resumed `activity` is covering the current leaseholder if i) the leaseholder
// itself isn't resumed, ii) `activity` isn't the leaseholder's host Activity, and iii)
// `activity` isn't itself a nav root (see [isActivityNavRoot]). (i) checks the leaseholder's
// own state rather than its host Activity's, as they differ for Fragment nav roots: a Fragment
// leaseholder that isn't resumed isn't what the user is interacting with, even if its host
// Activity is resumed.
/** Withdraws the lease when [activity] appears to cover a non-resumed nav root. */
private fun onActivityCreatedOrResumed(
activity: Activity,
event: NavActivityWatcher.ActivityEvent,
) {
// Assume the created or resumed `activity` is covering the current leaseholder if i) the
// leaseholder itself isn't resumed, ii) `activity` isn't the leaseholder's host Activity, and
// iii) `activity` isn't itself a nav root (see [isActivityNavRoot]). (i) checks the
// leaseholder's own state rather than its host Activity's, as they differ for Fragment nav
// roots: a Fragment leaseholder that isn't resumed isn't what the user is interacting with,
// even if its host Activity is resumed.
val holder = leaseHolder ?: return
val hostActivity = holder.hostActivity ?: return
if (
Expand All @@ -329,11 +337,28 @@ internal class NavLeaseCoordinator {
withdrawLease(
scope,
withdrawalReason =
"${activity.javaClass.name} was resumed while the nav root wasn't resumed",
"${activity.javaClass.name} was ${event.description} while the nav root wasn't resumed",
)
}
}

/**
* After [activity] is destroyed, asks the leaseholder to retry publication if an Activity's
* `ui.load` blocked it. The publisher decides whether the blocker has left and retry is allowed.
*/
private fun onAnyActivityDestroyed(activity: Activity) {
val holder = leaseHolder ?: return
if (holder.hostActivity === activity) {
return
}

holder.withScopeOrNull { scope ->
if (scope != null) {
holder.publisher.retryTransactionPublication(scope)
}
}
}

/**
* A [nav root][rootInfo] eligible to hold the lease managed by [NavLeaseCoordinator].
*
Expand Down Expand Up @@ -502,6 +527,12 @@ internal interface INavStatePublisher {
/** Publishes the candidate's staged nav state to [scope]. */
fun publishTo(scope: IScope)

/**
* Retries a nav transaction blocked by an Activity's `ui.load`, subject to
* [StagedNavTransaction.retryPublishIfBlocked]. Leaves other published nav state alone.
*/
fun retryTransactionPublication(scope: IScope)

/** Removes published nav state from [scope], retaining it for a later lease acquisition. */
fun unpublishFrom(scope: IScope)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ internal class NavScopes(
stagedNavTransaction.publishTo(scope)
}

override fun retryTransactionPublication(scope: IScope) {
stagedNavTransaction.retryPublishIfBlocked(scope)
}

override fun unpublishFrom(scope: IScope) {
stagedNavTransaction.unpublishFrom(scope)
stagedNavData.clearPublishedStateFrom(scope)
Expand Down Expand Up @@ -228,7 +232,9 @@ internal class NavScopes(
override fun startNavTransaction(request: NavTransactionRequest) {
stagedNavTransaction.stage(request)
if (hasLease) {
stagedNavTransaction.publishTo(delegate)
// Don't retry b/c ui.load retry is only for returns to a nav root, not for navigation
// within it.
stagedNavTransaction.publishTo(delegate, canRetry = false)
} else {
stagedNavData.deferPropagationContextRotation()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ leaving the transaction unbound but running until the leaseholder resumes.

Non-candidate roots never register, so the coordinator infers their presence from either of two signals:

- another Activity resumes while the leaseholder isn't resumed, unless the resumed Activity is
itself a nav root, which takes the lease when it resumes; or
- another Activity is created or resumed while the leaseholder isn't resumed, unless the resumed
Activity is itself a nav root, which takes the lease when it resumes; or

- the leaseholder pauses while its host Activity stays resumed (e.g., a ViewPager2 swipe). (Note
that an Activity pauses before its Fragments do when the Activity is backgrounded or covered.)
Expand All @@ -163,10 +163,19 @@ Non-candidate roots never register, so the coordinator infers their presence fro
(e.g., dialogs) go undetected. These two signals are deliberately the only ones: other surfaces that cover a leaseholder without pausing it
(e.g., bottom sheets or Compose dialogs) aren't detected, and shouldn't be by adding signals here.

**Limitation (before the covering Activity resumes)**: The lease is withdrawn only once the covering Activity resumes. Until then (i.e.,
during its `onCreate` and `onStart`), errors and spans the covering Activity produces are still attributed to the leaseholder. For the same
reason, the covering Activity's `ui.load` transaction isn't bound to the scope: `ActivityLifecycleIntegration` tries to bind it when the
Activity is created, while the leaseholder's nav transaction still holds the scope's transaction slot.
**Limitation (below API 29)**: An Activity created over the leaseholder doesn't get its `ui.load` transaction bound to the scope. There's no
`onActivityPreCreated` below API 29, so the lease is withdrawn only after `ActivityLifecycleIntegration` has tried to bind the Activity's
`ui.load` transaction and found the leaseholder's nav transaction in the scope's transaction slot. The integration doesn't try again, so its
`ui.load` transaction stays unbound, as it did before withdrawal existed. Withdrawal still keeps the Activity's errors and spans from being
attributed to the leaseholder: spans the Activity produces attach to no transaction until one is bound (e.g., the nav transaction for the
first destination of the Activity's own `SentryNavEffect`, if it has one). Closing this gap would take a change to
`ActivityLifecycleIntegration` that affects every app, so it's deliberately left open.

As a result, an Activity with its own `SentryNavEffect` gets a different transaction bound for its spans depending on the API level. On
API 29+, its `ui.load` transaction is bound, and its first destination's nav transaction isn't started because the slot is taken. Below
API 29, when the leaseholder's nav transaction held the slot as the Activity was created, its first destination's nav transaction is bound
instead. Either way, `ActivityLifecycleIntegration` still sends the `ui.load` transaction, including its TTID and TTFD spans. Only the
parent of auto-instrumented spans (e.g., HTTP or database spans) differs.

### Returning to a nav root

Expand All @@ -185,6 +194,24 @@ Either way, the transaction is bound only if the scope's transaction slot is fre
happen when the candidate publishes (`INavStatePublisher.publishTo`) its nav state, so the coordinator needn't tell a return from a first
acquisition: on its first back stack update, a candidate has no destination to start a transaction for yet.

When the user returns by closing an Activity, that Activity's `ui.load` transaction may still be running and hold the slot. It normally
leaves the slot when the closed Activity is destroyed, which happens after the nav root has resumed. If the Activity is destroyed within
`UI_LOAD_RETRY_WINDOW_NANOS` of the blocked attempt, binding the nav root's transaction is retried once (see `onAnyActivityDestroyed`).
Otherwise, the nav root goes without a bound nav transaction until its next navigation or return.

Only the current leaseholder is asked to retry when an Activity is destroyed. Its publisher checks whether the blocked `ui.load` has left
the slot and whether the retry window is still open. A leaseholder that loses or withdraws its lease forgets the blocked publication.

The retry has three gaps:

- A `ui.action` transaction started by the gesture that caused the return (e.g., a ViewPager2 swipe or an in-app back button) can block the
nav transaction. Only Activity `ui.load` transactions trigger this retry.
- Spans produced between the nav root's resume and the closed Activity's destruction may attach to the closed Activity's `ui.load`.
- An Activity left without being destroyed (e.g., when another Activity is reordered to the front) triggers no retry.

Unbinding a finishing Activity's `ui.load` when it pauses would close the last two gaps, but would change `ActivityLifecycleIntegration`
for every app. Giving navigation priority over `ui.action` is an SDK-wide scope-slot policy decision shared with other integrations.

A nav root that's destroyed and recreated after losing the lease (e.g., on a configuration change) isn't returned to: it registers anew and
gets a transaction on its first back stack update. See [#6454](https://github.com/getsentry/sentry-java/issues/6454).

Expand All @@ -194,7 +221,8 @@ existing Activity comes back to the foreground.

### Android ordering assumptions

`NavLeaseCoordinator` relies on the following sequences, which Android doesn't document but AOSP and androidx have historically upheld:
Besides the callback ordering `NavActivityWatcher` relies on, `NavLeaseCoordinator` relies on the following sequences, which Android doesn't
document but AOSP and androidx have historically upheld:

- An Activity's `onActivityResumed` callbacks run before its `Lifecycle` dispatches
`ON_RESUME` (see `isActivityNavRoot`).
Expand All @@ -203,5 +231,8 @@ existing Activity comes back to the foreground.
lifecycles do. androidx's `ReportFragment` and `FragmentActivity` ensure this, not the
platform.

- A visible Activity that finishes is destroyed once the Activity that replaces it reports idle,
or when the system's idle timeout fires (see `UI_LOAD_RETRY_WINDOW_NANOS`).

Going forward, if an order changes, nav data may be attributed to the wrong nav root, or a nav transaction may go unbound, but nothing would
throw.
Loading
Loading