Skip to content

Commit ecb6243

Browse files
authored
feat(android-nav3): [Android Nav3 4] Introduce SentryNavEffect (#6132)
Introduce SentryNavEffect as our primary entry point for Nav3 support. The initial aim is to provide parity with our existing Nav2 support. That means generating nav transactions, breadcrumbs, and screen names as the user navigates through the app. We also record up to N frames of the host app's back stack in current scope context. (N is 10 by default; it and other Sentry data configs can be adjusted via SentryNavOptions.) Similar to Nav2, we assume each tracked navigation destination represents the current screen's contents. We don't (yet) support the notion of Scenes (https://developer.android.com/guide/navigation/navigation-3/scenes) or multipane layouts. (Support for each is tracked under [JAVA-590](https://linear.app/getsentry/issue/JAVA-590/nav3-multipane-support). For more details, see the KDocs to SentryNavEffect, SentryNavOptions, RouteNameExtractor, and RouteArgumentsExtractor. SentryNavEffect is experimental and (for now) internal. We'll make it public in a later commit when we're ready to release.
1 parent d825db7 commit ecb6243

5 files changed

Lines changed: 798 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
@@ -46,7 +46,10 @@ android {
4646
checkReleaseBuilds = false
4747
}
4848

49-
buildFeatures { buildConfig = true }
49+
buildFeatures {
50+
buildConfig = true
51+
compose = true
52+
}
5053

5154
androidComponents.beforeVariants {
5255
it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType)
@@ -60,10 +63,13 @@ dependencies {
6063

6164
compileOnly(libs.androidx.compose.runtime)
6265

63-
testImplementation(libs.androidx.compose.runtime)
66+
testImplementation(libs.androidx.compose.ui.test.junit4)
67+
testImplementation(libs.androidx.test.core)
68+
testImplementation(libs.androidx.test.ext.junit)
6469
testImplementation(libs.google.truth)
6570
testImplementation(libs.mockito.inline)
6671
testImplementation(libs.mockito.kotlin)
72+
testImplementation(libs.roboelectric)
6773
}
6874

6975
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 entries)
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 routeExtractors = rememberUpdatedState(RouteExtractors(nameExtractor, argumentsExtractor))
101+
102+
val observer =
103+
remember(scopes, options) {
104+
BackStackObserver(
105+
scopes = scopes,
106+
options = options,
107+
extractors = { routeExtractors.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 org.junit.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)