Enable users to share your app's deep links using navigation-recents-url-sharing
Tags: android, androiddev, opensource, kotlin
Published: 2024-04-01 17:14:04

## tl;dr
This article is a companion to the [Navigation recents url sharing](https://github.com/StylianosGakis/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](https://github.com/StylianosGakis/navigation-recents-url-sharing/blob/1c93b8c85f6d6f2c5674e1ba69a8d5a6fb905064/sample/app/src/main/java/com/stylianosgakis/navigation/recents/url/sharing/sample/MainActivity.kt).

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h17zbebcli57a5ywe59c.png)

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](https://developer.android.com/guide/components/activities/recents#url-sharing) 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](https://developer.android.com/reference/android/app/Activity#onProvideAssistContent(android.app.assist.AssistContent)) function which gives you the [AssistContent](https://developer.android.com/reference/android/app/assist/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](https://developer.android.com/reference/android/app/assist/AssistContent#setWebUri(android.net.Uri)). 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1pstb3h56iwdha9sl903.png)

## 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](https://developer.android.com/reference/androidx/navigation/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](https://developer.android.com/reference/androidx/navigation/NavDestination#hasDeepLink(android.net.Uri)) 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

```kotlin
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
```kotlin
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
```kotlin
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
```kotlin
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
```kotlin
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](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-common/src/main/java/androidx/navigation/NavType.kt;l=59). 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:

```kotlin
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
```kotlin
return deepLinkWithPlaceholdersFilled.takeIf { !it.contains("{") }
```

All together, the code looks like this
```kotlin
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](https://github.com/StylianosGakis/navigation-recents-url-sharing) 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](https://github.com/StylianosGakis/navigation-recents-url-sharing/blob/main/library/navigation-recents-url-sharing/src/main/java/com/stylianosgakis/navigation/recents/url/sharing/ProvideAssistContent.kt)

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](https://mastodon.mbonnin.net/@mb) for helping me by reviewing the article.
I'd also like to thank [Ian Lake](https://androiddev.social/@ianlake) for helping me with finding all the right APIs from the androidx.navigation library.