Skip to content
Closed
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### Improvements

- 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))
- 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.

### Fixes

- 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))
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" }
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.3.5" }
androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidxLifecycle" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version = "2.8.3" }
androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" }
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidxNavigation" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3" }
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version = "1.1.0" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" }
Expand Down
2 changes: 1 addition & 1 deletion sentry-android-navigation3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ Because Nav3 back stacks can have arbitrary key types, host apps need to provide
keys and the data Sentry should display for each (see `BackStackEntryMapper`).

See the [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/) for additional details, including
installation, configuration, and limitations.
configuration, supported setups, and limitations.
5 changes: 5 additions & 0 deletions sentry-android-navigation3/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,15 @@ dependencies {
implementation(projects.sentry)

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

testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.fragment.ktx)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.viewpager2)
testImplementation(libs.google.truth)
testImplementation(libs.mockito.inline)
testImplementation(libs.mockito.kotlin)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,9 @@ internal data class NormalizedSentryBackStackEntry(
val name: String,
/** Sanitized [SentryBackStackEntry.arguments] (i.e., bounded in size and depth). */
val arguments: Map<String, Any?> = emptyMap(),
/** The reason why the SDK dropped host-provided arguments. */
/**
* The reason why the SDK dropped host-provided arguments, or null if no arguments were dropped.
*/
val argumentDropReason: ArgumentDropReason? = null,
) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@ package io.sentry.compose.navigation3

import io.sentry.Breadcrumb
import io.sentry.Hint
import io.sentry.IScope
import io.sentry.IScopes
import io.sentry.ITransaction
import io.sentry.PropagationContext
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLevel.DEBUG
import io.sentry.SentryLevel.INFO
import io.sentry.SentryOptions
import io.sentry.SpanStatus
import io.sentry.TransactionContext
import io.sentry.TransactionOptions
import io.sentry.compose.navigation3.BackStackConverter.RetentionPolicy
import io.sentry.compose.navigation3.PreparedChange.BackStackHasNewTop
import io.sentry.compose.navigation3.PreparedChange.BackStackHasSameTop
import io.sentry.compose.navigation3.PreparedChange.BackStackIsEmpty
import io.sentry.compose.navigation3.lifecycle.INavScope
import io.sentry.compose.navigation3.lifecycle.INavScopes
import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY
import io.sentry.protocol.App
import io.sentry.protocol.TransactionNameSource
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
Expand Down Expand Up @@ -47,10 +48,11 @@ private const val NAVIGATION_OP: String = "navigation"
* **Thread safety**
*
* This class is ***not*** thread-safe. Clients should serialize calls to [onBackStackChanged] and
* [cleanup] (e.g., via invocation from an `*Effect` or another form of thread confinement).
* [dispose] (e.g., via invocation from an `*Effect` or another form of thread confinement).
*/
@Suppress("TooManyFunctions")
internal class BackStackObserver<T : Any>(
private val scopes: IScopes,
private val scopes: INavScopes,
private val options: SentryNavOptions,
entryMapper: ForwardingBackStackEntryMapper<T>,
) {
Expand Down Expand Up @@ -110,23 +112,14 @@ internal class BackStackObserver<T : Any>(
scopes.configureScope { scope -> applyChange(scope, change) }
}

internal fun cleanup() {
previousTop = null
previousTopNormalized = null

scopes.configureScope { scope ->
navTransaction.stop(scope)
navScreen.clear(scope)

if (options.captureBackStack) {
// This observer owns the nav context while it's in the composition, and cleanup removes
// it to avoid leaking stale back stack data after observation stops. If the host app
// replaces one observer with another, there may be a brief gap where events lack nav
// context. Apps should keep the observer at the nav root so cleanup only runs when the
// navigation session is ending, not during normal destination changes.
navContext.clear(scope)
}
}
/**
* Clears the nav data this observer recorded.
*
* Call it when observation ends so that no stale screen, nav context, or nav transaction outlives
* it.
*/
internal fun dispose() {
scopes.configureScope { scope -> tearDown(scope) }
}

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

private fun applyChange(scope: IScope, change: PreparedChange<T>) {
private fun applyChange(scope: INavScope, change: PreparedChange<T>) {
when (change) {
is BackStackIsEmpty -> handleEmptyBackStack(scope)
is BackStackHasNewTop -> handleNewTop(scope, change.previousTop, change.backStack)
Expand Down Expand Up @@ -185,7 +178,7 @@ internal class BackStackObserver<T : Any>(
}

private fun handleNewTop(
scope: IScope,
scope: INavScope,
previousTop: NormalizedSentryBackStackEntry?,
currentBackStack: BackStackData<T>,
) {
Expand All @@ -212,23 +205,19 @@ internal class BackStackObserver<T : Any>(
)
?.let { transaction -> navContext.updateTransaction(transaction, scope, currentBackStack) }
} else {
// Rotate the propagation context.
scope.withPropagationContext { scope.setPropagationContext(PropagationContext()) }
scope.rotatePropagationContext()
}

storeAsPreviousTop(currentBackStack.topEntry, currentBackStack.topEntryNormalized)
}

private fun handleSameTop(scope: IScope, backStack: BackStackData<T>) {
private fun handleSameTop(scope: INavScope, backStack: BackStackData<T>) {
navContext.update(scope, backStack.capturedEntriesNormalized)
storeAsPreviousTop(backStack.topEntry, backStack.topEntryNormalized)
}

private fun handleEmptyBackStack(scope: IScope) {
navTransaction.stop(scope)
navContext.clear(scope)
navScreen.clear(scope)
clearPreviousTop()
private fun handleEmptyBackStack(scope: INavScope) {
tearDown(scope)
}

private fun storeAsPreviousTop(topEntry: T, topEntryNormalized: NormalizedSentryBackStackEntry) {
Expand All @@ -240,12 +229,19 @@ internal class BackStackObserver<T : Any>(
previousTop = null
previousTopNormalized = null
}

private fun tearDown(scope: INavScope) {
navTransaction.stop(scope)
navContext.clear(scope)
navScreen.clear(scope)
clearPreviousTop()
}
}

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

/** A helper class for managing nav transactions. */
private class NavTransaction(private val scopes: IScopes) {
private class NavTransaction(private val scopes: INavScopes) {

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

/** Starts an idle navigation transaction, or no-ops if another transaction is already active. */
fun start(
scope: IScope,
scope: INavScope,
name: String,
arguments: Map<String, Any?>,
): ITransaction? {
clearTransactionIfFinished(scope)
scope.clearFinishedTransaction()

if (scope.transaction != null) {
scopes.options.logger.log(
Expand Down Expand Up @@ -323,6 +319,7 @@ private class NavTransaction(private val scopes: IScopes) {
transactionOptions,
)

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

scope.withTransaction { tx ->
if (tx == null) {
scope.transaction = transaction
}
}
scope.bindTransaction(transaction)

return transaction
}

/** Finishes and unsets the active navigation transaction, if one exists. */
fun stop(scope: IScope) {
fun stop(scope: INavScope) {
val transaction = activeNavTransaction ?: return
val status = transaction.status ?: SpanStatus.OK
transaction.finish(status)

scope.withTransaction { tx ->
if (tx == transaction) {
scope.clearTransaction()
}
}

scope.unbindTransaction(transaction)
activeNavTransaction = null
}

/** Clears a stale finished transaction that's still bound to the default scope. */
private fun clearTransactionIfFinished(scope: IScope) {
scope.withTransaction { tx ->
if (tx?.isFinished == true) {
scope.clearTransaction()
}
}
}
}

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

private companion object {
private const val BACKSTACK_KEY = "Back Stack"
private const val NAVIGATION_CONTEXT_KEY = "Navigation"
}

fun update(scope: IScope, backStackEntries: List<NormalizedSentryBackStackEntry>) {
fun update(scope: INavScope, backStackEntries: List<NormalizedSentryBackStackEntry>) {
if (backStackEntries.isEmpty()) {
clear(scope)
return
}

scope.setContexts(NAVIGATION_CONTEXT_KEY, backStackEntries.toBackStackMap())
scope.setNavigationContext(backStackEntries.toBackStackMap())
}

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

/**
Expand All @@ -403,12 +378,11 @@ private class NavContext(private val scopes: IScopes, private val options: Sentr
*/
fun <T : Any> updateTransaction(
transaction: ITransaction,
scope: IScope,
scope: INavScope,
backStack: BackStackData<T>,
) {
if (scopes.options.isEnableScreenTracking) {
val appContext =
transaction.contexts.app ?: io.sentry.protocol.Contexts(scope.contexts).app ?: App()
val appContext = transaction.contexts.app ?: scope.app ?: App()

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

private var lastScreenName: String? = null

fun update(scope: IScope, currentEntry: NormalizedSentryBackStackEntry) {
fun update(scope: INavScope, currentEntry: NormalizedSentryBackStackEntry) {
scope.screen = currentEntry.name
lastScreenName = currentEntry.name
}

fun clear(scope: IScope) {
fun clear(scope: INavScope) {
val screenName = lastScreenName ?: return
if (scope.screen == screenName) {
scope.screen = null
Expand All @@ -447,7 +421,7 @@ private class NavScreen {
}

/** A helper class for generating nav breadcrumbs. */
private class NavBreadcrumbs(private val scopes: IScopes) {
private class NavBreadcrumbs(private val scopes: INavScopes) {

fun emit(fromEntry: NormalizedSentryBackStackEntry?, toEntry: NormalizedSentryBackStackEntry) {
val breadcrumb =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package io.sentry.compose.navigation3

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import io.sentry.IScope
import io.sentry.IScopes
import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator
import io.sentry.compose.navigation3.lifecycle.NavScopes

/**
* A [BackStackObserver] whose updates to a shared mutable [IScope] are coordinated with other
* `BackStackObserver`s.
*
* Coordination is achieved via [NavScopes].
*
* **Usage**
*
* Call [attach] before the first [onBackStackChanged], and call [dispose] when observation ends.
*
* Instances targeting the same [IScope] must be created with the same [NavLeaseCoordinator].
*
* **Thread safety**
*
* This class is ***not*** thread-safe. Invocations should be confined to the same thread as the
* [LifecycleEventObserver] callbacks implemented by [NavScopes].
*/
internal class CoordinatedBackStackObserver<T : Any>(
scopes: IScopes,
options: SentryNavOptions,
entryMapper: ForwardingBackStackEntryMapper<T>,
coordinator: NavLeaseCoordinator,
) {

private val navScopes = NavScopes(scopes, coordinator)
private val observer = BackStackObserver(navScopes, options, entryMapper)

fun attach(lifecycle: Lifecycle) {
navScopes.attach(lifecycle)
}

fun onBackStackChanged(backStack: List<T>) {
navScopes.onBackStackUpdate()
observer.onBackStackChanged(backStack)
}

fun dispose() {
// Call disposal on both the observer and navScopes in case their (currently overlapping)
// implementations drift over time.
observer.dispose()
navScopes.dispose()
}
}

/** The [NavLeaseCoordinator] shared by default by all [SentryNavEffect]s. */
internal val defaultNavLeaseCoordinator: NavLeaseCoordinator = NavLeaseCoordinator()
Loading
Loading