We’re all happy about Navigation 3, right? But before we get more stability and recipes, I had to fix something in Navigation 2′ use case. This post is about a hack to get things working. If you know a better way, definitely reach out to me.
The Problem
I’m working on an app that will be a mobile counterpart to the web. The links are localized and well-established, so changing their structure is not an option. I have a Gradle task that downloads deep link definitions and generates a bit of Kotlin code. That part is up to me — I can generate whatever I want.
The links look like this:
- https://cz.example.com/film/999-film-title-in-cz/recenze/
- https://en.example.com/film/999-film-title-in-en/reviews/
They basically:
- contain localized titles,
- contain localized static path segments (
film,recenze,reviews), - contain the ID in the same segment as the film title, just dash-separated.
Jetpack Navigation Compose allows you to specify deep link matchers when defining a destination. Our code-generator could generate all those deep link matchers for a single target, and the actual code would just use that definition:
// generated code
val FilmDeepLinks = listOf<NavDeepLink>(
navDeepLink { ... }
}
// use-site
@Serializable
data class FilmDestination(
val filmId: Int,
)
composable<FilmDestination>(deepLinks = FilmDeepLinks) { FilmScreen() }
Here comes the problem: the NavDeepLink type is super limited. Let’s take a look at the official API.
navDeepLink<FilmDestination>(basePath = "https://cz.example.com")
The example above shows the type-safe builder API. This way, all of FilmDestination’s parameters will be automatically added to the URI pattern. But that misses the point – we have a custom format, so we can’t use these auto-generated path segments/query parameters.
The old API (before type-safety) was a bit more verbose: basically, you defined the matching pattern yourself:
navDeepLink { uriPattern = "https://cz.example.com/film/{filmId}-.*/recenze" } // not working
The example above shows a simple solution using the uriPattern property. But it doesn’t work; simply because the placeholder must “consume” the entire path segment. It’s not possible to include a dash and some unrelated content in there.
While exploring the implementation of NavDeepLink, I discovered that the type-safe variant also works with a typeMap — a concept for type-safe string-to-property mapping. This way, the {filmId} placeholder could consume the whole path segment, and the type mapper would extract just the id in the beginning.
navDeepLink { uriPattern = "https://cz.example.com/film/{filmId}/recenze" } // not working
But the typeMap API isn’t publicly accessible. What’s more, I’d have to construct the entire map and wrap or modify the desired mappers (for the filmId argument).
Another idea was to parse the deep link URL myself, then construct a simplified internal URL and define deep links for that.
// deeplink processing
fun parseDeepLink(url: String): String? {
val result = FilmDeeplink.matchEntire(url)
if (result != null) return "myapp://film/${result.groups["id"]}"
return null
}
navController.handleDeepLink(parseDeepLink(url)) // simplified
// use-site
navDeepLink { uriPattern = "myapp://film/{filmId}" }
This worked — we’re getting somewhere! But it’s error-prone. We completely lose type-safety, and we duplicate the effort we’ve already put into creating destination objects.
What we actually want is for parseDeepLink to return the destination object.
// deeplink processing
fun parseDeepLink(url: String): Any? {
val result = FilmDeepLinkRegExp.matchEntire(url)
if (result != null) return FilmDestination(filmId = result.groups["id"].toInt())
return null
}
navController.handleDeepLink(parseDeepLink(url)) // not working
This looks promising, but there’s one thing missing. The handleDeepLink function is unable to process destination objects, and there’s no public API to convert a destination object back to a URL.
If you’re thinking about using navController.navigate(), yes, that would work — but only partially. This way, the app will navigate to the destination, but calling navigateUp() from there won’t be able to recreate the back stack because it compares the “up” destination to the “deep link,” which is null since we used navigate instead of handleDeepLink. I covered this flow in my talk about my (now-deprecated) NavigationComposeTyped library: https://www.youtube.com/watch?v=bXuiYvGFbvs. By the way, that library had a public API to convert objects to URLs.
The Solution
At this point, I was going crazy. No light at the end of the tunnel, even though I explored a lot. The biggest issue was that NavDeepLink is a final class; no public constructor, no way to extend it, and no way to implement custom specs that would parse arguments in a different way.
In JVM, we don’t have Objective-C’s swizzling — meaning, we can’t replace specific behaviors (methods) at runtime. But we do have the reflection API, which lets us access parts of the API we normally shouldn’t.
The idea is to convert the object to the internal representation and generate deep links using the internal representation pattern. Yay, is it that easy? Almost. First of all, be aware that the API changes — the following code is for Jetpack Navigation Compose 2.9.
fun convertDestinationToUrl(destination: Any, navController: NavController): String {
val ncGetImpl = NavController::class.java.getDeclaredField("impl").apply { isAccessible = true }
val nci = Class.forName("androidx.navigation.internal.NavControllerImpl")
val nciInstance = ncGetImpl.get(navController)
val nciGenerate = nci.getDeclaredMethod($$"generateRouteFilled$navigation_runtime_release", Any::class.java)
val route = nciGenerate.invoke(nciInstance, deeplink) as String
return route
}
This way, we get the URL for the destination object. It doesn’t include the scheme, but that’s a minor detail. It looks like this:
com.example.cz.myapp.FilmDestination/1
Next, we want to generate the matching pattern. The type-safe navDeepLink helps a lot — it processes all destination parameters and appends them as path segments or query parameters, but it misses the “destination name.” In this case, it misses the com.example.cz.myapp.FilmDestination part. That’s something we can obtain easily through reflection. However, Jetpack Navigation uses kotlinx.serialization for this, so let’s use that as well to avoid R8/Proguard issues.
inline fun <reified T : Any> myAppNavDeeplink(): NavDeepLink =
navDeepLink<T>(basePath = "myapp://" + serializer<T>().descriptor.serialName)
Using these building blocks makes it work quite seamlessly:
composable<FilmDestination>(
deepLinks = listOf(myAppNavDeeplink<FilmDestination>()),
) { ... }
// deep link handling
val destination = parseDeepLink(deepLinkUrl) ?: return
val routePath = convertDestinationToUrl(destination, navController)
val deepLink = NavDeepLinkRequest.Builder.fromUri(("myapp://" + routePath).toUri()).build()
navController.handleDeepLink(deepLink)
Finally, add keep rules to avoid issues in release builds:
# Keep nav controller and impl for reflection
-keep class androidx.navigation.internal.NavControllerImpl { *; }
-keep class androidx.navigation.NavController { *; }
Conclusion
Yeah, it was quite a hacky solution, but it works — that’s the point, isn’t it? To be honest, it took me more than four hours to properly deep-dive and understand what’s going on. But I survived!
The whole story has another aspect — I receive HTML content and format it in the app. That content also contains HTML links. Instead of relaunching the app, I catch those links, let the parseDeepLink() function process them into destination objects, and then navigate using those objects. This way, the user doesn’t lose the back stack, there’s a single place to handle deep links, and everything works smoothly.
In the end, I’m really looking forward to Navigation 3. 🤞