Skip to content

Commit ec8bdd9

Browse files
committed
feat(android-nav3): Introduce SentryNavEffect
Add the Compose-facing abstraction that binds Navigation 3 back stack changes to the observer layer. This establishes the integration’s primary API shape while keeping it internal until the rest of the sequence is ready to expose it.
1 parent d1870f0 commit ec8bdd9

5 files changed

Lines changed: 705 additions & 1 deletion

File tree

sentry-android-navigation3/build.gradle.kts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
55
plugins {
66
id("com.android.library")
77
alias(libs.plugins.kotlin.android)
8+
alias(libs.plugins.kotlin.compose)
89
alias(libs.plugins.gradle.versions)
910
alias(libs.plugins.detekt)
1011
}
@@ -47,7 +48,10 @@ android {
4748
checkReleaseBuilds = false
4849
}
4950

50-
buildFeatures { buildConfig = true }
51+
buildFeatures {
52+
buildConfig = true
53+
compose = true
54+
}
5155

5256
androidComponents.beforeVariants {
5357
it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType)
@@ -62,10 +66,14 @@ dependencies {
6266
compileOnly(libs.androidx.compose.runtime)
6367

6468
testImplementation(libs.androidx.compose.runtime)
69+
testImplementation(libs.androidx.compose.ui.test.junit4)
70+
testImplementation(libs.androidx.test.core)
71+
testImplementation(libs.androidx.test.ext.junit)
6572
testImplementation(libs.google.truth)
6673
testImplementation(libs.kotlin.test.junit)
6774
testImplementation(libs.mockito.inline)
6875
testImplementation(libs.mockito.kotlin)
76+
testImplementation(libs.roboelectric)
6977
}
7078

7179
tasks.withType<Detekt>().configureEach {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package io.sentry.compose.navigation3
2+
3+
/**
4+
* A key for distinguishing back stacks over time.
5+
*
6+
* Lets `*Effect`s restart when either the identity of a stack entry changes or the stack's entries
7+
* are reordered.
8+
*/
9+
internal class BackStackKey<T : Any>(private val backStack: List<T>) {
10+
11+
override fun equals(other: Any?): Boolean {
12+
// Use of identity rather than structural equality frees us from entries' equals() and
13+
// hashCode() implementations, which are provided by the host app and may be incomplete,
14+
// expensive, or incorrect for our purposes.
15+
if (this === other) {
16+
return true
17+
}
18+
if (other !is BackStackKey<*>) {
19+
return false
20+
}
21+
if (backStack.size != other.backStack.size) {
22+
return false
23+
}
24+
25+
return backStack.indices.all { index -> backStack[index] === other.backStack[index] }
26+
}
27+
28+
override fun hashCode(): Int {
29+
var result = backStack.size
30+
for (entry in backStack) {
31+
result = 31 * result + System.identityHashCode(entry)
32+
}
33+
return result
34+
}
35+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package io.sentry.compose.navigation3
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.DisposableEffect
5+
import androidx.compose.runtime.remember
6+
import androidx.compose.runtime.rememberUpdatedState
7+
import io.sentry.IScopes
8+
import io.sentry.ScopesAdapter
9+
import io.sentry.SentryOptions
10+
11+
/**
12+
* An effect for generating Sentry data from your Nav3 backstack. Configure it via [options] and
13+
* call it before you invoke your `NavDisplay`.
14+
*
15+
* ```kotlin
16+
* @Composable
17+
* fun AppNavigation() {
18+
* val navBackStack = rememberNavBackStack(Home)
19+
*
20+
* // Place SentryNavEffect in the same composable as your NavDisplay and call
21+
* // the effect first. Doing so ensures the effect's lifecycle matches your
22+
* // NavDisplay, and that any Sentry data produced by your nav destinations
23+
* // get attributed to the appropriate nav transaction.
24+
* SentryNavEffect(
25+
* backStack = navBackStack,
26+
* options = SentryNavOptions(maxCapturedBackStackEntries = 10),
27+
* nameExtractor = { route -> route.extractName() },
28+
* argumentsExtractor = { route -> route.extractArgument() },
29+
* )
30+
*
31+
* // Configure your NavDisplay like usual.
32+
* NavDisplay(
33+
* backStack = navBackStack,
34+
* ...
35+
* )
36+
* }
37+
* ```
38+
*
39+
* **Data generated**
40+
*
41+
* By default, the following data is produced for each nav destination:
42+
*
43+
* - a breadcrumb
44+
* - a screen name
45+
* - a record of the current back stack (last 10 frames)
46+
*
47+
* A new transaction is started at each nav destination, assuming another non-nav transaction isn't
48+
* already active.
49+
*
50+
* You can configure the above defaults via [SentryNavOptions]. (Screen names can be disabled via
51+
* [SentryOptions.setEnableScreenTracking].)
52+
*
53+
* **Limitations**
54+
*
55+
* `SentryNavEffect` generates all Sentry data based solely on the top entry of your back stack. In
56+
* particular, it has no awareness of
57+
* [`Scene`](https://developer.android.com/guide/navigation/navigation-3/scenes)s. Transaction
58+
* routes, breadcrumbs, and screen names are all derived from the top entry of the back stack and
59+
* are updated as it changes.
60+
*
61+
* `SentryNavEffect` also doesn't make any special accommodations for
62+
* [predictive back](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture)
63+
* gestures. That means, for instance, that spans produced by predictively rendered composables can
64+
* show up under the current destination's transaction.
65+
*
66+
* **Privacy / PII**
67+
*
68+
* Values returned from [nameExtractor] and [argumentsExtractor] are ***not*** scrubbed by the
69+
* Sentry SDK before being sent to Sentry. Only return route names and arguments that are known to
70+
* be safe or have been pre-scrubbed.
71+
*
72+
* @param backStack The navigation backstack to observe.
73+
* @param scopes A scopes instance used to track generated Sentry data.
74+
* @param options The kinds of navigation info this effect should record.
75+
* @param nameExtractor Optional lambda to extract a human-readable route name from the top entry of
76+
* the [backStack]. If not provided, defaults to the simple name of the entry's class.
77+
* @param argumentsExtractor Optional lambda to extract a map of argument name -> argument values
78+
* from the top entry of the [backStack]. If not provided, no arguments are attached. The
79+
* following scalar values are supported: [String], [CharSequence], [Char], [Boolean], any
80+
* [Number], enums (via [Enum.name]), and `null`. Supported container values are: [Array]s,
81+
* primitive arrays, [Map]s, and [Collection]s of supported values, including nested containers.
82+
* All other types are stringified via `toString()`. Cyclic or deeply nested containers are
83+
* skipped. Return only the arguments needed for diagnostics and avoid large structures.
84+
*/
85+
@Composable
86+
@Suppress("FunctionNaming")
87+
internal fun <T : Any> SentryNavEffect(
88+
backStack: List<T>,
89+
scopes: IScopes = ScopesAdapter.getInstance(),
90+
options: SentryNavOptions = SentryNavOptions(),
91+
nameExtractor: ((T) -> String)? = null,
92+
argumentsExtractor: ((T) -> Map<String, Any?>)? = null,
93+
) {
94+
val routeResolvers = rememberUpdatedState(RouteResolvers(nameExtractor, argumentsExtractor))
95+
96+
val observer =
97+
remember(scopes, options) {
98+
BackStackObserver(
99+
scopes = scopes,
100+
options = options,
101+
resolvers = { routeResolvers.value },
102+
)
103+
}
104+
105+
val capturedBackStack = backStack.toList()
106+
107+
DisposableEffect(observer, BackStackKey(capturedBackStack)) {
108+
observer.onBackStackChanged(backStack = capturedBackStack)
109+
onDispose {}
110+
}
111+
112+
DisposableEffect(observer) {
113+
onDispose { observer.cleanup() }
114+
}
115+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package io.sentry.compose.navigation3
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import kotlin.test.Test
5+
6+
class BackStackKeyTest {
7+
8+
private data class HomeScreen(val dummy: String = "")
9+
10+
private data class ProfileScreen(val userId: String)
11+
12+
@Test
13+
fun `keys are equal when entry identity and order are equal`() {
14+
val home = HomeScreen()
15+
val profile = ProfileScreen("123")
16+
17+
val first = BackStackKey(listOf(home, profile))
18+
val second = BackStackKey(listOf(home, profile))
19+
20+
assertThat(first).isEqualTo(second)
21+
assertThat(first.hashCode()).isEqualTo(second.hashCode())
22+
}
23+
24+
@Test
25+
fun `keys are not equal when entries are equal by value but not by identity`() {
26+
val first = BackStackKey(listOf(ProfileScreen("123")))
27+
val second = BackStackKey(listOf(ProfileScreen("123")))
28+
29+
assertThat(first).isNotEqualTo(second)
30+
}
31+
32+
@Test
33+
fun `keys are not equal when entry order changes`() {
34+
val home = HomeScreen()
35+
val profile = ProfileScreen("123")
36+
37+
val first = BackStackKey(listOf(home, profile))
38+
val second = BackStackKey(listOf(profile, home))
39+
40+
assertThat(first).isNotEqualTo(second)
41+
}
42+
43+
@Test
44+
fun `keys are not equal when stack size changes`() {
45+
val home = HomeScreen()
46+
47+
val first = BackStackKey(listOf(home))
48+
val second = BackStackKey(listOf(home, ProfileScreen("123")))
49+
50+
assertThat(first).isNotEqualTo(second)
51+
}
52+
53+
@Test
54+
fun `equals does not call entry equals`() {
55+
val entry = ExplodingEqualityKey()
56+
57+
val first = BackStackKey(listOf(entry))
58+
val second = BackStackKey(listOf(entry))
59+
60+
assertThat(first).isEqualTo(second)
61+
}
62+
63+
@Test
64+
fun `hash code does not call entry hash code`() {
65+
val entry = ExplodingEqualityKey()
66+
67+
val first = BackStackKey(listOf(entry))
68+
val second = BackStackKey(listOf(entry))
69+
70+
assertThat(first.hashCode()).isEqualTo(second.hashCode())
71+
}
72+
73+
private class ExplodingEqualityKey {
74+
75+
override fun equals(other: Any?): Boolean = error("equals boom")
76+
77+
override fun hashCode(): Int = error("hashCode boom")
78+
}
79+
}

0 commit comments

Comments
 (0)