Skip to content

Commit cffd83e

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 7e1db9e commit cffd83e

5 files changed

Lines changed: 800 additions & 2 deletions

File tree

‎sentry-android-navigation3/build.gradle.kts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ android {
4848
checkReleaseBuilds = false
4949
}
5050

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

5356
androidComponents.beforeVariants {
5457
it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType)
@@ -62,11 +65,14 @@ dependencies {
6265

6366
compileOnly(libs.androidx.compose.runtime)
6467

65-
testImplementation(libs.androidx.compose.runtime)
68+
testImplementation(libs.androidx.compose.ui.test.junit4)
69+
testImplementation(libs.androidx.test.core)
70+
testImplementation(libs.androidx.test.ext.junit)
6671
testImplementation(libs.google.truth)
6772
testImplementation(libs.kotlin.test.junit)
6873
testImplementation(libs.mockito.inline)
6974
testImplementation(libs.mockito.kotlin)
75+
testImplementation(libs.roboelectric)
7076
}
7177

7278
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: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
import org.jetbrains.annotations.ApiStatus
11+
12+
/**
13+
* An effect for generating Sentry data from your Nav3 backstack. Configure it via [options] and
14+
* call it before you invoke your `NavDisplay`.
15+
*
16+
* ```kotlin
17+
* @Composable
18+
* fun AppNavigation() {
19+
* val navBackStack = rememberNavBackStack(Home)
20+
*
21+
* // Place SentryNavEffect in the same composable as your NavDisplay and call
22+
* // the effect first. Doing so ensures the effect's lifecycle matches your
23+
* // NavDisplay, and that any Sentry data produced by your nav destinations
24+
* // get attributed to the appropriate nav transaction.
25+
* SentryNavEffect(
26+
* backStack = navBackStack,
27+
* nameExtractor = { route -> route.extractName() },
28+
* argumentsExtractor = { route -> route.extractArgument() },
29+
* options = SentryNavOptions(),
30+
* )
31+
*
32+
* // Configure your NavDisplay like usual.
33+
* NavDisplay(
34+
* backStack = navBackStack,
35+
* ...
36+
* )
37+
* }
38+
* ```
39+
*
40+
* **Data generated**
41+
*
42+
* By default, the following data is produced for each nav destination:
43+
*
44+
* - a breadcrumb
45+
* - a screen name
46+
* - a record of the current back stack (last 10 frames)
47+
*
48+
* A new transaction is started at each nav destination, assuming another non-nav transaction isn't
49+
* already active.
50+
*
51+
* You can configure the above defaults via [SentryNavOptions]. (Screen names can be disabled via
52+
* [SentryOptions.setEnableScreenTracking].)
53+
*
54+
* **Limitations**
55+
*
56+
* `SentryNavEffect` generates all Sentry data based solely on the top entry of your back stack. In
57+
* particular, it has no awareness of
58+
* [`Scene`](https://developer.android.com/guide/navigation/navigation-3/scenes)s. Transaction
59+
* routes, breadcrumbs, and screen names are all derived from the top entry of the back stack and
60+
* are updated as it changes.
61+
*
62+
* `SentryNavEffect` also doesn't make any special accommodations for
63+
* [predictive back](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture)
64+
* gestures. That means, for instance, that spans produced by predictively rendered composables can
65+
* show up under the current destination's transaction.
66+
*
67+
* @param backStack The navigation backstack to observe.
68+
* @param nameExtractor Extracts a human-readable route name from each entry of the [backStack].
69+
* @param argumentsExtractor Optional extractor for a map of argument name -> argument values from
70+
* each entry of the [backStack]. If not provided, no arguments are attached.
71+
* @param options The kinds of navigation info this effect should record.
72+
*/
73+
@ApiStatus.Experimental
74+
@Composable
75+
@Suppress("FunctionNaming")
76+
internal fun <T : Any> SentryNavEffect(
77+
backStack: List<T>,
78+
nameExtractor: RouteNameExtractor<T>,
79+
argumentsExtractor: RouteArgumentsExtractor<T>? = null,
80+
options: SentryNavOptions = SentryNavOptions(),
81+
) {
82+
SentryNavEffect(
83+
backStack = backStack,
84+
nameExtractor = nameExtractor,
85+
argumentsExtractor = argumentsExtractor,
86+
options = options,
87+
scopes = ScopesAdapter.getInstance(),
88+
)
89+
}
90+
91+
@Composable
92+
@Suppress("FunctionNaming")
93+
internal fun <T : Any> SentryNavEffect(
94+
backStack: List<T>,
95+
nameExtractor: RouteNameExtractor<T>,
96+
argumentsExtractor: RouteArgumentsExtractor<T>? = null,
97+
options: SentryNavOptions = SentryNavOptions(),
98+
scopes: IScopes,
99+
) {
100+
val routeResolvers = rememberUpdatedState(RouteResolvers(nameExtractor, argumentsExtractor))
101+
102+
val observer =
103+
remember(scopes, options) {
104+
BackStackObserver(
105+
scopes = scopes,
106+
options = options,
107+
resolvers = { routeResolvers.value },
108+
)
109+
}
110+
111+
// The incoming back stack is mutable and shared with the host app; copy it so that BackStackKey
112+
// and BackStackObserver are guaranteed to have the same (stable) view.
113+
val copy = backStack.toList()
114+
115+
DisposableEffect(observer, BackStackKey(copy)) {
116+
observer.onBackStackChanged(backStack = copy)
117+
onDispose {}
118+
}
119+
120+
DisposableEffect(observer) {
121+
onDispose { observer.cleanup() }
122+
}
123+
}
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)