Handle Deep links For In-App Content In A Single Activity Compose Architecture

Ahmed Samir
9 min readOct 25, 2022

--

Kaize boiz

Handling deep links can come a little bit tricky when handling it in a single activity Compose project especially when you are not used to them, as we only have one activity and multiple Compose functions. But if you think about it is the same as our beloved single-activity multiple-fragments architecture. We only defined one activity that holds our manifest file, and there is where our intent-filters belong. Additionally, starting from Android 12 (API 31) if your app holds a link (that has HTTP/HTTPS schemes), ends with a domain (such as .com, .net, etc), and is not verified or approved as your app URL, then the OS will automatically launch the intent within the device’s default browser.

The Android docs have an easy-to-follow guide if you’re willing to make your domains verified across your apps, which are usually connected to the websites of your business and eventually land you in your in-app content even in Android 12. However, if you, for instance, create an application only for mobile or Android platforms and you don’t really tend to target the web platform currently by any chance or for instance you didn’t want to verify your URL for any cause, then handling them manually would suffice.

1. Project & deep links setup

Let’s dive into setting up our project. If you want to follow along, clone this repository from the initial branch or switch to the final branch for the complete code.

If you are following along with an emulator or a device that has Android 12 then testing within the app won’t work for you unless you apply the step mentioned in Handling deep links in Android 12

The starter project has everything to get you started, a couple of dependencies for navigation, Compose, and Room Database. Since we are going to learn about deep links only, we won’t go over the Room DB code, though you can take a look at the code at your leisure.

Our use case is very simple, we are going to have a prepopulated database. And we are going to use deep links to navigate to a particular item within the list. Similarly, you could use it with Retrofit to perform some network requests accordingly.

Now to setup up a deep link for our project, we have to add the intent-filter to our manifest file and make sure you place it within the <activity> tag body like so:

Our intent-filter consists of multiple elements, let’s look at each in brief detail according to the docs:

<category android:name="android.intent.category.DEFAULT" />: This allows your app to respond to implicit intents.

<category android:name="android.intent.category.BROWSABLE"/>: It is required for the intent filter to be accessible from a web browser. Without it, clicking a link in a browser cannot resolve to your app.

<action android:name="android.intent.action.VIEW" />: Specify the ACTION_VIEW intent action so that the intent filter can be reached from Google Search.

<data …>: defines your URL elements in the format (scheme://host/path). Note that the path has other variants which you could look at in the docs linked above. The scheme element can be anything rather than HTTP/HTTPS if you’re not supporting the web. However, we used HTTPS intentionally for this use case, so we see how we fail to resolve to activity with Android 12 & onwards.

Wait, what? we said that we will verify our links manually, then why did we use category.BROWSABLE & action.VIEW? Well, they might seem redundant to have but they’re crucial elements that will help in the verification/association step in Handling deep links in Android 12.

if you test the deep link with the command line it would open your current state of the activity, which is currently empty. Let’s create our navigation graph and screens to test it.

In MainActivity.kt:

What we implemented here is just:

  • Two screens
    1- Home: showing a list of users coming from ViewModel.
    2- Details: showing details of the currently viewed user
  • Added arguments to Destination.Details
  • Made Destination.Details handle a deep link that is similar to our URL defined in the Manifest file.

Notice how once we are done with our uriPattern we had to start adding arguments “?userId={userId}”. And that’s because this deep link will be used to navigate to the details screen which by default takes an argument that we specified in “arguments = …”. Failing to do so, will cause a crash. Now the final Uri will be as:

https://deeplinkapp.com/sharing-user?userId=the-id-of-the-user-you-clicked-on)

And here’s the code for Destinations.kt:

Now write this command in your terminal passing a user id between 1–5:

adb shell am start -W -a android.intent.action.VIEW -d "https://deeplinkapp.com/sharing-user?userId=1"

Before it used to launch our activity or our startDestination of our Navigation graph, but now it launches the destination that handles the deep link uri in the graph. Though trying to test “https://deeplinkapp.com/sharing-user” only or with other arguments that are not specified, then it would still launch your activity as there is no composable that handles this specific URI alone.

Now the approach of getting the user is not very preferable:

val  user = users.find { it.id == userId }

And that’s because of two reasons:

  • the user initially would be null because of the nature of Compose infrastructure
  • We are getting the user from the currently displayed list

What if the data received from DB is paginated which means not all users are loaded locally unless we scroll, or for instance, we didn’t have the data in our DB and would rather make a network request? Or maybe you have some condition that tells the detail screen how to behave when the item is null. In our code, we show a dialog to ask the user if they want to add this unfound user. So, with this approach the dialog shows for a couple of milliseconds even if the user is found which is kind of bothersome.

Now change your implementation to the following within the composable of details destination

// details screen
composable(
route = Destinations.Details.route,
deepLinks = listOf(
navDeepLink {
val c = this@MainActivity
val scheme = c.getString(R.string.scheme)
val host = c.getString(R.string.host)
val path = c.getString(R.string.path_to_existed_user)
val uri = "$scheme://$host$path?userId={userId}"
uriPattern = uri
}
),
arguments = listOf(
navArgument("userId") {
this.type = NavType.IntType
}
)
) { navBackStackEntry ->
var user: User? by remember { mutableStateOf(null) }
var showDetailsScreen by remember { mutableStateOf(false) }
LaunchedEffect(key1 = Unit) {
val userId = navBackStackEntry.arguments?.getInt("userId")
user = viewModel.getUser(userId)
showDetailsScreen = true
}
if (showDetailsScreen){
DetailsScreen(
user = user,
onDismiss = { navController.popBackStack() },
onAddNewUserClicked = {

}
,
onSharing = {

}
)
}
}

What this patch does, is just awaits the result of finding that id in our local DB. Then launches the screen whether the result is found or null. eventually, we granted control over the state of showing the adding-dialog.

2. Deep-linking with multiple screens

What happens when the item is null, and the user clicks the add on the add-dialog? We simply launch another screen. Beforehand, we should do some modifications to our code:

  • Add the following class in the Destinations.kt:
  • Add another <data> element in the same intent-filter in your manifest file like so:

Though you can create another <intent-filter> and add the same <category> and <action> elements then a different <data>, but since we use the same scheme and host we can leave our data here in the same <intent-filter> you can learn more about this here.

  • And finally add the screen that should handle your new url:

Notice how we are passing multiple arguments to our URL, though it is not mandatory to pass navigation arguments like we did before, but the inverse is true.

Now by default when we navigate to the add screen without any arguments passed our user will have default values, but once we try to navigate with arguments then these values are automatically inserted in our textfields. Before testing implement the callback of the dialog like the following so we launch the adding destination.

DetailsScreen(
user = user,
onDismiss = {
// implement this
navController.popBackStack()
},
onAddNewUserClicked = {
// and this
navController.popBackStack()
navController.navigate(Destinations.AddNewUser.route)
},
onSharing = {

}
)

So, if we try the following command after running and building.

adb shell am start -W -a android.intent.action.VIEW -d "https://deeplinkapp.com/sharing-user?userId=12"

Where userId=12 which does not exist, the dialog will show up. Clicking add on the dialog will take us to the adding-screen.

You could also pass the URI string to navigate function which will have the same result.

Now for a trivial example yet important use case, say you’re sharing a link to add resource to the app, and this is where we are going to test the link of add-screen in the command line.

adb shell am start -W -a android.intent.action.VIEW -d "https://deeplinkapp.com/adding-user?name=some+name\&desc=some+description\&joinedYear=2022\&isElite=true"

The result would be:

The screen got the values from the deep link applied them to the screen’s fields. Notice when we test using the command line we have to escape the ‘&’ symbol by adding a backslash ‘\’.

What is left is to enable sharing so we can test our deep links in our emulator.

I made this helping singleton class to help us create and share our URLs. Now let’s implement onSharing callback in our DetailsScreen:

DetailsScreen(
user = user,
onDismiss = { navController.popBackStack() },
onAddNewUserClicked = {
navController.popBackStack()
navController.navigate(Destinations.AddNewUser.route)
},
onSharing = {
// add this
user?.let{ nonNullableUser ->
SharingUtil.shareUserUrl(this@MainActivity, nonNullableUser)
}
}
)
link shared in the messages app

Now, clicking the share icon in our DetailsScreen will generate the link, and clicking the link after sharing it will resolve to that particular user in your app.

3. Handling the default behavior of deep links manually in Android 12 (API 31) and onwards

According to the note mentioned here, links must be first approved first to resolve to your app content, or it will launch the web browser as the default link handler. One of the ways mentioned to solve this issue is to request the user to associate the domain with our app. As we said before if you don’t want to go through all the hassle to verify that your app holds the domain etc. Then we would let the user verify the link at runtime.

I have made a helper function that checks if the link is selected and associated with our app as it will later launch an intent to let the user verify it.

By using the DomainVerificationManager we could check the current state of our domain which is unapproved by default in Android 12 & later. So, after getting the domain state related to our app we check if the user has selected our app to be the default handler for our domains, as well as if the user has disabled link handling in the settings for our app.

if either of these conditions is met, then we launch the dialog telling the user why we need our app to be associated with our domain with additional instructions, so we don’t leave him/her in a daze.

By default, we are calling this function in the DetailsScreen composable, so the user will see the dialog once he/she clicks on a list’s item

Now let’s test and run the app on Android 12 or later:

The dialog shows up, user clicks on open settings, then enables “open supported links”, and adds supported links, and finally, if we ever clicked a link shared from our app or tried to test the link with the command line, it will resolve to our activity.

Whenever the user disables “open supported links” or remove our link from the supported link the dialog will show up again the details screen, but the user can simply ignore it by clicking ignore.

Now remember when we said we needed category.BROWSABLE & action.VIEW? Well, without them the “+ Add link” option wouldn’t be there so we wouldn’t be able to associate the app with our desired link.

Where am I using this approach? I used it in my last hobby project Githuber

Thank you for reading this far, if you have any concerns, please feel free to leave them in the comments, and if you liked the article don’t forget to give me a clap 👏🏻👏🏻👏🏻👏🏻👏🏻.

--

--