Enable users to share your app's deep links using navigation-recents-url-sharing

gakisstylianos

Stylianos Gakis

Posted on April 1, 2024

Enable users to share your app's deep links using navigation-recents-url-sharing

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

A screenshot of the Android recents screen, showing an icon which allows the user to click to get the YouTube link to the current video that is being watched

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 Hedvig app showing the deep link URL being available in the Android recents screen

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
Enter fullscreen mode Exit fullscreen mode

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("{") }
Enter fullscreen mode Exit fullscreen mode

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("{") }
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
gakisstylianos
Stylianos Gakis

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