-
-
Notifications
You must be signed in to change notification settings - Fork 478
feat(android-nav3): [Android Nav3 4] Introduce SentryNavEffect #6132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/sentry-nav3-effect-3-observer
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package io.sentry.compose.navigation3 | ||
|
|
||
| /** | ||
| * A key for distinguishing back stacks over time. | ||
| * | ||
| * Lets `*Effect`s restart when either the identity of a stack entry changes or the stack's entries | ||
| * are reordered. | ||
| */ | ||
| internal class BackStackKey<T : Any>(private val backStack: List<T>) { | ||
|
|
||
| override fun equals(other: Any?): Boolean { | ||
| // Use of identity rather than structural equality frees us from entries' equals() and | ||
| // hashCode() implementations, which are provided by the host app and may be incomplete, | ||
| // expensive, or incorrect for our purposes. | ||
| if (this === other) { | ||
| return true | ||
| } | ||
| if (other !is BackStackKey<*>) { | ||
| return false | ||
| } | ||
| if (backStack.size != other.backStack.size) { | ||
| return false | ||
| } | ||
|
|
||
| return backStack.indices.all { index -> backStack[index] === other.backStack[index] } | ||
| } | ||
|
|
||
| override fun hashCode(): Int { | ||
| var result = backStack.size | ||
| for (entry in backStack) { | ||
| result = 31 * result + System.identityHashCode(entry) | ||
| } | ||
| return result | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| package io.sentry.compose.navigation3 | ||
|
|
||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.runtime.DisposableEffect | ||
| import androidx.compose.runtime.remember | ||
| import androidx.compose.runtime.rememberUpdatedState | ||
| import io.sentry.IScopes | ||
| import io.sentry.ScopesAdapter | ||
| import io.sentry.SentryOptions | ||
| import org.jetbrains.annotations.ApiStatus | ||
|
|
||
| /** | ||
| * An effect for generating Sentry data from your Nav3 backstack. Configure it via [options] and | ||
| * call it before you invoke your `NavDisplay`. | ||
| * | ||
| * ```kotlin | ||
| * @Composable | ||
| * fun AppNavigation() { | ||
| * val navBackStack = rememberNavBackStack(Home) | ||
| * | ||
| * // Place SentryNavEffect in the same composable as your NavDisplay and call | ||
| * // the effect first. Doing so ensures the effect's lifecycle matches your | ||
| * // NavDisplay, and that any Sentry data produced by your nav destinations | ||
| * // get attributed to the appropriate nav transaction. | ||
| * SentryNavEffect( | ||
| * backStack = navBackStack, | ||
| * nameExtractor = { route -> route.extractName() }, | ||
| * argumentsExtractor = { route -> route.extractArgument() }, | ||
| * options = SentryNavOptions(), | ||
| * ) | ||
| * | ||
| * // Configure your NavDisplay like usual. | ||
| * NavDisplay( | ||
| * backStack = navBackStack, | ||
| * ... | ||
| * ) | ||
| * } | ||
| * ``` | ||
| * | ||
| * **Data generated** | ||
| * | ||
| * By default, the following data is produced for each nav destination: | ||
| * | ||
| * - a breadcrumb | ||
| * - a screen name | ||
| * - a record of the current back stack (last 10 frames) | ||
| * | ||
| * A new transaction is started at each nav destination, assuming another non-nav transaction isn't | ||
| * already active. | ||
| * | ||
| * You can configure the above defaults via [SentryNavOptions]. (Screen names can be disabled via | ||
| * [SentryOptions.setEnableScreenTracking].) | ||
| * | ||
| * **Limitations** | ||
| * | ||
| * `SentryNavEffect` generates all Sentry data based solely on the top entry of your back stack. In | ||
| * particular, it has no awareness of | ||
| * [`Scene`](https://developer.android.com/guide/navigation/navigation-3/scenes)s. Transaction | ||
| * routes, breadcrumbs, and screen names are all derived from the top entry of the back stack and | ||
| * are updated as it changes. | ||
| * | ||
| * `SentryNavEffect` also doesn't make any special accommodations for | ||
| * [predictive back](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture) | ||
| * gestures. That means, for instance, that spans produced by predictively rendered composables can | ||
| * show up under the current destination's transaction. | ||
| * | ||
| * @param backStack The navigation backstack to observe. | ||
| * @param nameExtractor Extracts a human-readable route name from each entry of the [backStack]. | ||
| * @param argumentsExtractor Optional extractor for a map of argument name -> argument values from | ||
| * each entry of the [backStack]. If not provided, no arguments are attached. | ||
| * @param options The kinds of navigation info this effect should record. | ||
| */ | ||
| @ApiStatus.Experimental | ||
| @Composable | ||
| @Suppress("FunctionNaming") | ||
| internal fun <T : Any> SentryNavEffect( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will be the main public API for our Nav3 integration – at least for milestone 1. (Milestone 1 aims at Nav2 parity, including support of navigation where each entry in the back stack is interpreted as the currently visible screen). Marked as experimental b/c this will be made public in a follow-on to the current PR stack. |
||
| backStack: List<T>, | ||
| nameExtractor: RouteNameExtractor<T>, | ||
| argumentsExtractor: RouteArgumentsExtractor<T>? = null, | ||
| options: SentryNavOptions = SentryNavOptions(), | ||
| ) { | ||
| SentryNavEffect( | ||
| backStack = backStack, | ||
| nameExtractor = nameExtractor, | ||
| argumentsExtractor = argumentsExtractor, | ||
| options = options, | ||
| scopes = ScopesAdapter.getInstance(), | ||
| ) | ||
| } | ||
|
|
||
| @Composable | ||
| @Suppress("FunctionNaming") | ||
| internal fun <T : Any> SentryNavEffect( | ||
| backStack: List<T>, | ||
| nameExtractor: RouteNameExtractor<T>, | ||
| argumentsExtractor: RouteArgumentsExtractor<T>? = null, | ||
| options: SentryNavOptions = SentryNavOptions(), | ||
| scopes: IScopes, | ||
| ) { | ||
| val routeResolvers = rememberUpdatedState(RouteResolvers(nameExtractor, argumentsExtractor)) | ||
|
|
||
| val observer = | ||
| remember(scopes, options) { | ||
| BackStackObserver( | ||
| scopes = scopes, | ||
| options = options, | ||
| resolvers = { routeResolvers.value }, | ||
| ) | ||
| } | ||
|
|
||
| // The incoming back stack is mutable and shared with the host app; copy it so that BackStackKey | ||
| // and BackStackObserver are guaranteed to have the same (stable) view. | ||
| val copy = backStack.toList() | ||
|
|
||
| DisposableEffect(observer, BackStackKey(copy)) { | ||
| observer.onBackStackChanged(backStack = copy) | ||
| onDispose {} | ||
| } | ||
|
|
||
| DisposableEffect(observer) { | ||
| onDispose { observer.cleanup() } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| package io.sentry.compose.navigation3 | ||
|
|
||
| import com.google.common.truth.Truth.assertThat | ||
| import kotlin.test.Test | ||
|
|
||
| class BackStackKeyTest { | ||
|
|
||
| private data class HomeScreen(val dummy: String = "") | ||
|
|
||
| private data class ProfileScreen(val userId: String) | ||
|
|
||
| @Test | ||
| fun `keys are equal when entry identity and order are equal`() { | ||
| val home = HomeScreen() | ||
| val profile = ProfileScreen("123") | ||
|
|
||
| val first = BackStackKey(listOf(home, profile)) | ||
| val second = BackStackKey(listOf(home, profile)) | ||
|
|
||
| assertThat(first).isEqualTo(second) | ||
| assertThat(first.hashCode()).isEqualTo(second.hashCode()) | ||
| } | ||
|
|
||
| @Test | ||
| fun `keys are not equal when entries are equal by value but not by identity`() { | ||
| val first = BackStackKey(listOf(ProfileScreen("123"))) | ||
| val second = BackStackKey(listOf(ProfileScreen("123"))) | ||
|
|
||
| assertThat(first).isNotEqualTo(second) | ||
| } | ||
|
|
||
| @Test | ||
| fun `keys are not equal when entry order changes`() { | ||
| val home = HomeScreen() | ||
| val profile = ProfileScreen("123") | ||
|
|
||
| val first = BackStackKey(listOf(home, profile)) | ||
| val second = BackStackKey(listOf(profile, home)) | ||
|
|
||
| assertThat(first).isNotEqualTo(second) | ||
| } | ||
|
|
||
| @Test | ||
| fun `keys are not equal when stack size changes`() { | ||
| val home = HomeScreen() | ||
|
|
||
| val first = BackStackKey(listOf(home)) | ||
| val second = BackStackKey(listOf(home, ProfileScreen("123"))) | ||
|
|
||
| assertThat(first).isNotEqualTo(second) | ||
| } | ||
|
|
||
| @Test | ||
| fun `equals does not call entry equals`() { | ||
| val entry = ExplodingEqualityKey() | ||
|
|
||
| val first = BackStackKey(listOf(entry)) | ||
| val second = BackStackKey(listOf(entry)) | ||
|
|
||
| assertThat(first).isEqualTo(second) | ||
| } | ||
|
|
||
| @Test | ||
| fun `hash code does not call entry hash code`() { | ||
| val entry = ExplodingEqualityKey() | ||
|
|
||
| val first = BackStackKey(listOf(entry)) | ||
| val second = BackStackKey(listOf(entry)) | ||
|
|
||
| assertThat(first.hashCode()).isEqualTo(second.hashCode()) | ||
| } | ||
|
|
||
| private class ExplodingEqualityKey { | ||
|
|
||
| override fun equals(other: Any?): Boolean = error("equals boom") | ||
|
|
||
| override fun hashCode(): Int = error("hashCode boom") | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.