Skip to content
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

Add type-safe navigation #1413

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open

Add type-safe navigation #1413

wants to merge 23 commits into from

Conversation

dturner
Copy link
Collaborator

@dturner dturner commented May 3, 2024

Overview
Navigation 2.8.0-alpha08 introduces type-safe APIs for the Navigation DSL. Instead of using strings for routes and argument names, you can define types which are verified at compile-time. This blog post explains more about the API design.

What have I done and why
Migrated the navigation code from strings to types. For example, here's the old navigation code for the topic feature:

const val TOPIC_ID_ARG = "topicId"
const val TOPIC_ROUTE = "topic_route"

fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {
    val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
    val newRoute = "$TOPIC_ROUTE/$encodedId"
    navigate(newRoute) {
        navOptions()
    }
}

fun NavGraphBuilder.topicScreen(
    showBackButton: Boolean,
    onBackClick: () -> Unit,
    onTopicClick: (String) -> Unit,
) {
    composable(
        route = "topic_route/{$TOPIC_ID_ARG}",
        arguments = listOf(
            navArgument(TOPIC_ID_ARG) { type = NavType.StringType },
        ),
    ) {
        TopicScreen(...)
    }
}

And here's the new type-safe code:

@Serializable data class TopicDestination(val id: String)

fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {
    navigate(route = TopicDestination(topicId)) {
        navOptions()
    }
}

fun NavGraphBuilder.topicScreen(
    showBackButton: Boolean,
    onBackClick: () -> Unit,
    onTopicClick: (String) -> Unit,
) {
    composable<TopicDestination> {
        TopicScreen(...)
    }
}

Note that there's no need to URL encode the arguments because there's no danger of them being interpreted as placeholders (e.g. "{topicId}") in the route string (because there is no route string).

I have also:

  • Refactored InterestsListDetailScreen to avoid the need for Interests2PaneViewModel since it was only providing a single property
  • Updated the app manifest to allow deeplinks to be tested from the terminal. Example: adb shell am start -a android.intent.action.VIEW -d "https://www.nowinandroid.apps.samples.google.com/foryou/2" com.google.samples.apps.nowinandroid.demo.debug

TODO
I haven't figured out a way of injecting the route/destination into SavedStateHandle for ViewModel tests. I'm currently hardcoding any argument names which is enough for the route to be created from toRoute.

Here's an example for TopicViewModelTest:

fun setup() {
        viewModel = TopicViewModel(
            savedStateHandle = SavedStateHandle(mapOf("id" to testInputTopics[0].topic.id)),
            ...
        )
}

What I want to do is:

savedStateHandle = SavedStateHandle(route = TopicDestination(id = testInputTopics[0].topic.id))

dturner and others added 7 commits May 1, 2024 13:46
Change-Id: Idf4386f10c780d3edc1f8aa11b428cb146e982c3
Change-Id: I4f3c310be693ecbcbc8b99c4e573d7fc6e9a2f74
Change-Id: I72f617c594b5e0ae272cf94d2d7288446153420a
Change-Id: I02a8efb46695b3a90701966bfea4ed76aeec131b
…e string value

Change-Id: I4767578028b55c2bc7b1763bdeef87345b9fbf06
Change-Id: Ia112f87c7f1bb7fa9ebe08b82d26e00b4ad17d05
Copy link

github-actions bot commented May 3, 2024

Combined test coverage report

Overall Project 40.34% -0.49% 🍏
Files changed 57.67%

Module Coverage
foryou 56.16% -3.2%
bookmarks 51.21% -1.59%
interests 49.95% -3.74%
topic 47.33% -2.09%
app 32% -0.09% 🍏
Files
Module File Coverage
foryou ForYouNavigation.kt 0% -66.81%
bookmarks BookmarksNavigation.kt 0% -43.18%
interests InterestsViewModel.kt 100% 🍏
InterestsNavigation.kt 48.81% -45.83%
InterestsScreen.kt 25.7% -0.09%
topic TopicViewModel.kt 73.38% 🍏
TopicScreen.kt 49.85% 🍏
TopicNavigation.kt 33.62% -31.9%
app TopLevelDestination.kt 100% 🍏
InterestsListDetailScreen.kt 92.15% -0.69% 🍏
NiaAppState.kt 90.61% -1.04% 🍏
NiaApp.kt 83.44% 🍏
NiaNavHost.kt 81.03% 🍏

Copy link

github-actions bot commented May 3, 2024

Combined test coverage report

Overall Project 40.34% -0.49% 🍏
Files changed 55.61%

Module Coverage
foryou 56.16% -3.2%
bookmarks 51.21% -1.59%
interests 49.95% -3.69%
topic 47.33% -2.09%
app 32% -0.09% 🍏
Files
Module File Coverage
foryou ForYouNavigation.kt 0% -66.81%
bookmarks BookmarksNavigation.kt 0% -43.18%
interests InterestsViewModel.kt 100% 🍏
InterestsNavigation.kt 48.81% -45.24%
InterestsScreen.kt 25.7% -0.09%
topic TopicViewModel.kt 73.38% 🍏
TopicScreen.kt 49.85% 🍏
TopicNavigation.kt 33.62% -31.9%
app TopLevelDestination.kt 100% 🍏
InterestsListDetailScreen.kt 92.15% -0.69% 🍏
NiaAppState.kt 90.61% -1.04% 🍏
NiaApp.kt 83.44% 🍏
NiaNavHost.kt 81.03% 🍏


fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
composable(
route = FOR_YOU_ROUTE,
composable<ForYouDestination>(
deepLinks = listOf(
navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does DEEP_LINK_URI_PATTERN link up to creating the ForYouDestination data class? Is it important that linkedNewsResourceId is named in the same in the uri pattern and the ForYouDestination object?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, another good spot. The name of the argument placeholder in the deeplink currently has to be identical to the property in the ForYouDestination. That duplication needs to be removed.

The new API offers the following:

navDeepLink<ForYouDestination>(basePath = DEEP_LINK_URI_PATTERN),

However, I'm not sure how the mapping works between the serializable data class property ForYouDestination.linkedNewsResourceId and the deeplink URI format (currently https://www.nowinandroid.apps.samples.google.com/foryou/{linkedNewsResourceId}).

In our specific case it doesn't actually matter, as long as it works, because the only place it's used is when creating a PendingIntent for a notification (in SystemTrayNotifier) so there's no external dependency on the deeplink URI pattern. However, I can imagine that in other apps it'd be difficult to change the deeplink URI pattern so we should figure out how to do this migration.

I will ask the Jetpack team to clarify guidance here. Incidentally, the AndroidX sample has the same issue: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-compose/integration-tests/navigation-demos/src/main/java/androidx/navigation/compose/demos/NavByDeepLinkDemo.kt;l=54 userId is both a string in the URI, and a property of Dashboard.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kotlin Serialization supports having a different serialized value from the property name with @SerialName, which I would assume is the mechanism we could make use of here if they didn't match.

Copy link

@claraf3 claraf3 May 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The kdoc here explains how the final route is generated for deeplinks.

In this case, since linkedNewsResourceId has a default value (null), it would be appended as a query parameter.

You'd probably want DEEP_LINK_URI_PATTERN to be https://www.nowinandroid.apps.samples.google.com/foryou, and you would end up with this deeplink
https://www.nowinandroid.apps.samples.google.com/foryou?linkedNewsResourceId={linkedNewsResourceId}

On a side note, please feel free to let me know how we might improve the kdocs for this. I want to make sure people understand what the final deeplink looks like.

Copy link
Contributor

@Jaehwa-Noh Jaehwa-Noh May 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claraf3 What had happened when default value was null?

https://github.com/android/nowinandroid/pull/1413/files#diff-212cadaca92be197fc67569daea8119f465e4cfa25e34c4439bfa9c373b868a5R31-R32

@Serializable data class ForYouDestination(val linkedNewsResourceId: String? = null)

I expect that shows like this.
https://www.nowinandroid.apps.samples.google.com/foryou?linkedNewsResourceId=
Do I right expect?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nulls get encoded to a string literal null, so it will be
https://www.nowinandroid.apps.samples.google.com/foryou?linkedNewsResourceId=null

Copy link
Collaborator Author

@dturner dturner May 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @claraf3. According to the docs, in order to keep our existing format, which uses path parameters, we have to have a destination argument without a default value.

Changing the destination's constructor to change the deeplink pattern results in some duplicated code at the call site. For example, we now have 2 calls to ForYouDestination(linkedNewsResourceId=null) which is an obvious case for a default value, but if I add a default value, the argument is represented as a query parameter which breaks our original format.

Any advice here?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the consequences of breaking the original format? If it is absolutely crucial to keep it, a workaround is to add a deeplink to the NavDestination with a new class where the linkedNewsResourceId does not have a default value.

For example

@Serializable
data class ForYouDestinationAlternative(val linkedNewsResourceId: String?)

This way you can navigate to the destination with either deeplink:
https://www.nowinandroid.apps.samples.google.com/foryou?linkedNewsResourceId=null
https://www.nowinandroid.apps.samples.google.com/foryou/null

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the consequences of breaking the original format?

None for this app, but could be severe for another app wanting to migrate to use these APIs.

I've updated the PR to include this workaround just to demonstrate that it's possible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After online discussion, since the workaround was removed, I think we need to update the corresponding logic in SystemTrayNotifier to use the query parameter instead of the path parameter.

Change-Id: Ia5bb0abd366653aff5cf5a772ac11a837e96b9b1
Copy link

github-actions bot commented May 3, 2024

Combined test coverage report

Overall Project 40.37% -0.49% 🍏
Files changed 55.94%

Module Coverage
foryou 56.16% -3.2%
bookmarks 51.21% -1.59%
interests 49.95% -3.69%
topic 47.33% -2.09%
app 32.04% -0.09% 🍏
Files
Module File Coverage
foryou ForYouNavigation.kt 0% -66.81%
bookmarks BookmarksNavigation.kt 0% -43.18%
interests InterestsViewModel.kt 100% 🍏
InterestsNavigation.kt 48.81% -45.24%
InterestsScreen.kt 25.7% -0.09%
topic TopicViewModel.kt 73.38% 🍏
TopicScreen.kt 49.85% 🍏
TopicNavigation.kt 33.62% -31.9%
app TopLevelDestination.kt 100% 🍏
InterestsListDetailScreen.kt 92.15% -0.69% 🍏
NiaAppState.kt 90.61% -1.04% 🍏
NiaApp.kt 83.44% 🍏
NiaNavHost.kt 81.61% 🍏

…lScreen

Change-Id: I659f729191bce00683b1621c360c8f36f00595f9
Copy link

github-actions bot commented May 3, 2024

Combined test coverage report

Overall Project 40.36% -0.49% 🍏
Files changed 58.1%

Module Coverage
foryou 56.16% -3.2%
bookmarks 51.21% -1.59%
interests 49.95% -3.69%
topic 47.33% -2.09%
app 32.1% -0.06% 🍏
Files
Module File Coverage
foryou ForYouNavigation.kt 0% -66.81%
bookmarks BookmarksNavigation.kt 0% -43.18%
interests InterestsViewModel.kt 100% 🍏
InterestsNavigation.kt 48.81% -45.24%
InterestsScreen.kt 25.7% -0.09%
topic TopicViewModel.kt 73.38% 🍏
TopicScreen.kt 49.85% 🍏
TopicNavigation.kt 33.62% -31.9%
app TopLevelDestination.kt 100% 🍏
InterestsListDetailScreen.kt 93.88% -0.28% 🍏
NiaAppState.kt 90.61% -1.04% 🍏
NiaApp.kt 83.44% 🍏
NiaNavHost.kt 81.61% 🍏


fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
composable(
route = FOR_YOU_ROUTE,
composable<ForYouDestination>(
deepLinks = listOf(
navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN },
Copy link

@claraf3 claraf3 May 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The kdoc here explains how the final route is generated for deeplinks.

In this case, since linkedNewsResourceId has a default value (null), it would be appended as a query parameter.

You'd probably want DEEP_LINK_URI_PATTERN to be https://www.nowinandroid.apps.samples.google.com/foryou, and you would end up with this deeplink
https://www.nowinandroid.apps.samples.google.com/foryou?linkedNewsResourceId={linkedNewsResourceId}

On a side note, please feel free to let me know how we might improve the kdocs for this. I want to make sure people understand what the final deeplink looks like.

app/build.gradle.kts Outdated Show resolved Hide resolved

fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
composable(
route = FOR_YOU_ROUTE,
composable<ForYouDestination>(
deepLinks = listOf(
navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN },
Copy link
Contributor

@Jaehwa-Noh Jaehwa-Noh May 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claraf3 What had happened when default value was null?

https://github.com/android/nowinandroid/pull/1413/files#diff-212cadaca92be197fc67569daea8119f465e4cfa25e34c4439bfa9c373b868a5R31-R32

@Serializable data class ForYouDestination(val linkedNewsResourceId: String? = null)

I expect that shows like this.
https://www.nowinandroid.apps.samples.google.com/foryou?linkedNewsResourceId=
Do I right expect?

@@ -60,7 +59,8 @@ class TopicViewModelTest {
@Before
fun setup() {
viewModel = TopicViewModel(
savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)),
// TODO: Figure out how to supply the correct dependency TopicDestination(id = testInputTopics[0].topic.id)
Copy link
Contributor

@Jaehwa-Noh Jaehwa-Noh May 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is ViewModel Unit test, so that I don't think here is the place to consider Navigation Args.
Manual insert SavedStateHandle looks fine to me.

Additionally, SavedStateHandle saved data as Bundle, then my conclusion is you can't try this format

savedStateHandle = SavedStateHandle(route = TopicDestination(id = testInputTopics[0].topic.id))

Here is the support types.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what he meant was to be able to not rely on the TOPIC_ID_ARG hardcoded parameter, and instead being able to generate a SavedStateHandle from a TopicDestination instance.
There is this extension that takes a SavedStateHandle and returns your Destination data class:

public inline fun <reified T> SavedStateHandle.toRoute(): T = serializer<T>().decodeArguments(this)

There should probably be a test fixture to do the opposite. The closest I found is in RouteSerializerKt#generateRouteWithArgs() but that does not solves the issue entirely.

I suppose the key is based on the Serializable, and therefore could be accessed in tests with TopicDestination::topicId.name

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

…inor tidy ups.

Change-Id: Icfa79eac6f7327c365f79fd7d15dfa1f8c77184d
Change-Id: Ia5ab36f2d75d8d061e63c0cac5262503bc93a638
Change-Id: Ia197b2403e8250720387123d8c5d5d9ad809a036
Change-Id: Ia7460a618b2ddf8a25debce92308573bc18368a8
Change-Id: I535ca7fcec48c32b727c1c6c465a719d60dcb0f9
Change-Id: I63c0abb16cbf540ef72dfb945518a48113bdbf75
* main:
  Save nested nav key in instance state
  🤖 Updates baselines for Dependency Guard
  Recreate nested nav to work with AnimatedPane
  Remove forgotten Trace.endSection()
  Offload connectivity monitor to a background thread

Change-Id: I4002a07484a4d633c57406aedabf9f5d813a8592
Change-Id: I99703db37712abb46b844beb73bb14ddd5283165
Change-Id: Idb9586752d815449243d6d529e9b655c45e395ad
Change-Id: I55e779abe4ee49dc93916d9e1184ed81e1b2bbd0
Copy link

Combined test coverage report

Overall Project 40.58% -0.5% 🍏
Files changed 52.96%

Module Coverage
foryou 56.21% -3.25%
bookmarks 52.3% -1.59%
interests 48.73% -3.8%
topic 47.23% -2.09%
app 33.08% -0.08% 🍏
Files
Module File Coverage
foryou ForYouNavigation.kt 0% -70.18%
bookmarks BookmarksNavigation.kt 0% -43.18%
interests InterestsViewModel.kt 95.35% -3.88% 🍏
InterestsNavigation.kt 48.81% -45.24%
topic TopicViewModel.kt 72.69% 🍏
TopicScreen.kt 49.85% 🍏
TopicNavigation.kt 33.62% -31.9%
app TopLevelDestination.kt 100% 🍏
Interests2PaneViewModel.kt 91.89% -8.11% 🍏
NiaAppState.kt 90.61% -1.04% 🍏
InterestsListDetailScreen.kt 89.9% -0.1% 🍏
NiaApp.kt 83.44% 🍏
NiaNavHost.kt 81.61% 🍏

Change-Id: I2f01dc19e37b9fe890ad861909f57198cc5262d4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants