Enable users to share your app's deep links using navigation-recents-url-sharing
Stylianos Gakis
Posted on April 1, 2024
tl;dr
This article is a companion to the Navigation recents url sharing library. If you are using androidx.navigation as your nav solution, just import the library and go through the README to learn how to use it. With that you will get this behavior for free.
A sample application using it also exists inside this repository here.
Background
The other day, I was using YouTube on my phone. I swiped up to open a different app, but before I managed to do that something caught my eye. A little link-like icon on the top right
Clicking it did an amazing thing, it allowed me to copy and share the current link of the YouTube video I was looking at! At this point I was both amazed that this is possible and surprised how I had never seen it before! The only sensible thing that had to follow was figuring out how I can get my apps to do the same.
The Recents URL sharing feature
Sure enough, after some digging I stumbled upon the documentation which explains exactly how this works. It also explains why you do not typically see this in most apps, since it's something that the devs need to wire up themselves.
Android provides the onProvideAssistContent function which gives you the AssistContent object to manipulate yourself. This function is called every time the user goes into the "Recents" screen in supported phones. You are allowed to give more than just a URL to the AssistContent
object, but the one function we are interested in here is setWebUri. Whatever URI we provide there will show up exactly as the YouTube screenshot shows above.
The end goal is to be able to see something like this in our app
The implementation
But how do we know what we want to show up there? This of course relies on the app containing deep links to specific destinations in the app in the first place. If the screen we are currently in does not have a deep link, we can just show nothing. If there is such a deep link, we will try to show it.
androidx.navigation to the rescue
If you are using androidx.navigation already, I've got good news. We can use the NavController to get the current destination. Having that and the full list of the app's deep links we can iterate over them to find if any of those deep links match the current destination's deep links.
NavDestination then conveniently exposes a hasDeepLink function which takes in a Uri and does exactly that for us. With that alone, it's not hard to imagine a simple function which does this to extract the current deep link and pass it to AssistContent
fun NavController.provideAssistContent(outContent: AssistContent, allDeepLinkUriPatterns: List<String>) {
val backStackEntry: NavBackStackEntry? = currentBackStackEntry
val navDestination: NavDestination? = backStackEntry?.destination
for (deepLinkUriPattern in allDeepLinkUriPatterns) {
if (navDestination?.hasDeepLink(deepLinkUriPattern.toUri()) == true) {
outContent.webUri = deepLinkUriPattern.toUri()
break
}
}
}
And to wire this into your Activity, you'd have to add this
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
navController?.provideAssistContent(outContent, allDeepLinkUriPatterns)
}
This already does what we want! But there's one problem. What if there is a deep link that has a parameter placeholder? A deep link may look something like this https://your.app/conference/{conferenceId}
. You may be at a destination which has this deep link, and the function above will happily match it and let the user copy the string above as-is, with the {conferenceId}
part inside of it.
This is definitely not what we need
Make it work for deep links that contain parameters
For this to work, we need to make some assumptions. The list of deep links must contain proper deep link strings that androidx.navigation would work with. That means that the parameter names match the ones from the destination, and that they are wrapped in curly braces.
Some examples of such good deep links are
https://your.app/conference
https://your.app/conference/{conferenceId}
https://your.app/venue?venueId={venueId}
With those assumptions in place, we can yet again leverage the androidx.navigation APIs to be able to go from knowing our current NavDestination
to filling in the right parameters of the deep link.
In the provideAssistContent
function we got above, instead of exiting eagerly as soon as we find the first deep link, we can instead do two more things to ensure it is what we want
- Check if it has no placeholders, in which case we got a valid deep link and we can again exit eagerly
- Check if the deep link match has placeholders. If it does we do some more checks
Filling the placeholders with the destination's real parameters
We create a function with the following signature
private fun constructWebUriOrNull(
deepLinkUriPattern: String,
backStackEntry: NavBackStackEntry,
navDestination: NavDestination,
): String?
To this we will pass the deepLinkUriPattern which we are currently checking. The current destination's NavBackStackEntry
and the NavDestination
. With those three we can rebuild the real deep link, or just return null if we can not replace all placeholders
To maintain the existing behavior for deep links without placeholders, we can write a simple early return
if (!deepLinkUriPattern.contains("{")) {
return deepLinkUriPattern
}
This just returns the match as-is.
Let's now try to extract a map of all the argument names to their real values that we will want to replace inside the deepLinkUriPattern
val backStackEntryArguments: Bundle = backStackEntry.arguments ?: return null
val argumentNameToRealValueList = navDestination.arguments.map { (argumentName: String, navArgument: NavArgument) ->
val serializedTypeValue = navArgument.type.serializeAsValue(navArgument.type[backStackEntryArguments, argumentName])
argumentName to serializedTypeValue
}
Here we make use of a bunch of the things that androidx.navigation exposes to us.
We get the argumentName
from navDestination.arguments
. This is what should match the name inside the placeholder surrounded by the curly braces.
NavType
then exposes this super convenient operator fun get(bundle: Bundle, key: String): T
function. This, along with the NavType.serializeAsValue
function allows us to get the real value out of a bundle if we know its name. In this scenario, the name is exactly the aforementioned argumentName
, and the bundle is backStackEntry.arguments
.
To illustrate, for a deep link like https://your.app/conference/{conferenceId}
, where we navigated to this destination with conferenceId being kotlinconf2024
our map will now be ["conferenceId" -> "kotlinconf2024"]
The one that is left now is to go over the original string and try and replace all the placeholders. We can do that by folding over the initial deepLinkUriPattern
, and trying to replace all instances of the map's key surrounded by {}
with the real value.
In code:
val deepLinkWithPlaceholdersFilled =
argumentNameToRealValueList.fold(initial = deepLinkUriPattern) { acc, (argumentName: String, value: String?) ->
acc.replace("{$argumentName}", value)
}
Finally we can ensure that we have replaced all placeholders, and return the correct string if it has no more placeholders left
return deepLinkWithPlaceholdersFilled.takeIf { !it.contains("{") }
All together, the code looks like this
fun NavController.provideAssistContent(outContent: AssistContent, allDeepLinkUriPatterns: List<String>) {
val backStackEntry: NavBackStackEntry? = currentBackStackEntry
val navDestination: NavDestination? = backStackEntry?.destination
for (deepLinkUriPattern in allDeepLinkUriPatterns) {
if (navDestination?.hasDeepLink(deepLinkUriPattern.toUri()) != true) {
continue
}
val webUri: String? = constructWebUriOrNull(deepLinkUriPattern, backStackEntry, navDestination)
if (webUri != null) {
outContent.webUri = webUri.toUri()
break
}
}
}
private fun constructWebUriOrNull(
deepLinkUriPattern: String,
backStackEntry: NavBackStackEntry,
navDestination: NavDestination,
): String? {
if (!deepLinkUriPattern.contains("{")) {
// If no parameters exist in the deep link, just use it as-is
return deepLinkUriPattern
}
val backStackEntryArguments: Bundle = backStackEntry.arguments ?: return null
val argumentNameToRealValueList = navDestination.arguments.map { (argumentName: String, navArgument: NavArgument) ->
val serializedTypeValue = navArgument.type.serializeAsValue(navArgument.type[backStackEntryArguments, argumentName])
argumentName to serializedTypeValue
}
val deepLinkWithPlaceholdersFilled =
argumentNameToRealValueList.fold(initial = deepLinkUriPattern) { acc, (argumentName: String, value: String?) ->
acc.replace("{$argumentName}", value)
}
return deepLinkWithPlaceholdersFilled.takeIf { !it.contains("{") }
}
Conclusion
After all this work, your app now will automatically let you copy a link to the screen you are currently looking at, if such a deep link exists.
And the best part is that you can get this behavior more or less for free as long as you are using androidx.navigation as your navigation solution.
To make it all simpler for people who want to give this a try, I've published this little library which exposes a single public function which does all this for you. All you need to give it is your NavController
and the list of all the deep links that you would like to be considered as candidates.
The code is very minimal so if you do not want to bring in a new dependency for this, feel free to just copy the code for yourself, it's all in this one file
As always, if you give this a try but find any issues or you find it is not working for you in some scenario, I would really appreciate it if you created a GitHub issue for it, or better yet help out by fixing it yourself and making a pull request.
Gotchas
The one drawback with the current implementation is that the deep links are checked in order and we just use the first match we find. So if a screen has two deep links, the first one passed to allDeepLinkUriPatterns
will always win. The androidx.navigation library has a much more sophisticated approach, where all the matches are found and then are compared for the "most correct" match. That could potentially also be replicated here, but I decided to keep it simple for now.
Acknowledgements
I'd like to thank Martin Bonnin for helping me by reviewing the article.
I'd also like to thank Ian Lake for helping me with finding all the right APIs from the androidx.navigation library.
Posted on April 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
April 1, 2024