In the previous article about app architecture, I covered how to build your Domain, Data, and Presentation layers. If you haven’t read it yet, I would strongly recommend doing that so we can be on the same page.
Today I’ll show you how we can embrace this architecture adding deep link support to your app.
I’m not going to basics, I’m assuming you are already familiar with deep links. I want to show you how you can architecture it.
Android platform provides native support for deep links in the Android SDK. Popular frameworks like Fragment Navigation and Compose Navigation provide API to work with deep links. The one common problem that API has, is they are all platform-dependent. As we have learned about architecture, it is better to decouple logic from platform dependencies as much as possible. That is what I’m going to do today.
Let’s start with the Domain layer and create GetDeeplinkUseCase
.
class GetDeeplinkUseCase( | |
private val deeplinkRepository: DeeplinkRepository, | |
) { | |
suspend operator fun invoke(uri: String): DeepLink { | |
val deepLink = deeplinkRepository.getDeepLink(parsedUri) | |
return deepLink | |
} | |
} |
Here you can see a simple use case that takes URI as input and return DeepLink
object as a result. We also need a repository that knows how to parse URI and return deep link types that we know how to work with.
interface DeeplinkRepository { | |
suspend fun getDeepLink(uri: String): DeepLink | |
} |
The DeepLink
is just an interface in the Domain layer.
interface DeepLink |
All the code above we put in the shared
module in our app. The implementation of these interfaces will be in the app
module, which knows everything in our app and brings together all feature modules in the app.
The DeepLinkRepository
implementation will be in the data layer in app
module.
class DeeplinkDataRepository( | |
private val localDataSource: AppDeepLinkLocalDataSource, | |
) : DeeplinkRepository { | |
override suspend fun getDeepLink(uri: String): DeepLink { | |
return localDataSource.getDeepLinkData(uri) | |
} | |
} |
As you can see it contains local DataSoure
for deep links. There also can be a remote one, if you want to keep all the knowledge about deep links on your server, but let’s stop for a moment and define what type of deep links we can face in our project.
The Deep Links Type
The first and most common deep link is the regular URL that we can open with our app and navigate the user to the right screen with the needed state based on data from the deep link.
https://moove.com/ticket/confirmation?ryderId=4g5g4&price=43434
For example, with the URL above we can parse and understand that this is the confirmation screen for buying tickets and we can get ryderId
and price
from it. Also, we can open this URL in the browser and get to the confirmation page.
The second type is with the custom scheme.
moove://app/confirmation?ryderId=4g5g4&price=43434
It has the same structure as a regular URL but with a custom scheme moove
and custom path. This is equivalent to the Web URL above but can be understood only by the app. There is often a case when you don’t have a web page equivalent to the app screen, but the business wants to use deep links in the marketing campaigns and put users on the screen they want. In that case, it’s a good solution to create a unique deep link to your project and use it for that need.
As I’ve mentioned in the marketing campaigns it’s time to introduce the third type of deep link. The Deferred link or Dynamic link. This is the link that masks the original one and can be parsed only with third-party services, for instance, Firebase dynamic link, and Adjust Deferred link.
https://moove.page.link/45hj45j
Usually, it’s the short custom URL created by the third-party services that know how to parse it. You can use this URL in your marketing campaigns and when the user clicks on it, it will open the app and deliver the original deep link.
For example, when the user clicks on such a link the app opens and we get this URL as input.
https://moove.page.link/45hj45j
By the scheme, we can understand that this is the deferred link, and use the third-party services SDK to parse it and get the original one. In our case, it will be the next deep link.
moove://app/confirmation?ryderId=4g5g4&price=43434
This is the link we know how to parse into the app readable form.
Now is the right time to back to our local DataSource
of deep links and take a look at the code.
class AppDeepLinkLocalDataSource( | |
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO, | |
) { | |
companion object { | |
private const val RYDER_ID = "ryderId" | |
private const val PRICE = "price" | |
const val HOME = "moove://app/home" | |
const val FARE_LIST = "moove://app/fare_list" | |
const val CONFIRM_CONFIRMATION = "/ticket/confirmation" | |
const val MOOVE_CONFIRM_CONFIRMATION = "moove://app/confirmation" | |
} | |
suspend fun getDeepLinkData(uri: String): DeepLink = withContext(backgroundDispatcher) { | |
when { | |
uri.matchesPattern(CONFIRM_CONFIRMATION) -> { | |
val innerUri = URI.create(uri) | |
val params = getQueryParams(innerUri) | |
AppDeepLink.Confirmation( | |
ryderId = params[RYDER_ID]!!, | |
fare = Fare( | |
description = "", | |
price = params[PRICE]?.toFloat()!! | |
), | |
) | |
} | |
uri.matchesPattern(MOOVE_CONFIRM_CONFIRMATION) -> { | |
val innerUri = URI.create(uri) | |
val params = getQueryParams(innerUri) | |
AppDeepLink.Confirmation( | |
ryderId = params[RYDER_ID]!!, | |
fare = Fare( | |
description = "", | |
price = params[PRICE]?.toFloat()!! | |
), | |
) | |
} | |
uri.isThat(FARE_LIST) -> { | |
val innerUri = URI.create(uri) | |
val params = getQueryParams(innerUri) | |
AppDeepLink.FareList(ryderId = params[RYDER_ID]!!) | |
} | |
uri.isThat(HOME) || uri.matchesPattern(HOME) -> AppDeepLink.Home | |
else -> AppDeepLink.Unknown | |
} | |
} | |
private fun String.isThat(type: String): Boolean { | |
/** | |
* Handle two cases with slash symbol at the end and without it | |
* app/home/ and app/home | |
*/ | |
return contains(type, ignoreCase = true) | |
} | |
private fun getQueryParams(url: URI): Map<String, String> { | |
val query = url.query ?: return emptyMap() | |
return query | |
.split("&".toRegex()) | |
.filter { it.isNotEmpty() } | |
.map(::mapQueryParameter) | |
.associateBy(keySelector = { it.first }, valueTransform = { it.second }) | |
} | |
private fun mapQueryParameter(query: String): Pair<String, String> { | |
val index = query.indexOf("=") | |
val key = if (index > 0) query.substring(0, index) else query | |
val value = if (index > 0 && query.length > index + 1) { | |
query.substring(index + 1) | |
} else null | |
return Pair( | |
URLDecoder.decode(key, StandardCharsets.UTF_8.name()), | |
URLDecoder.decode(value, StandardCharsets.UTF_8.name()) | |
) | |
} | |
} |
A lot of code as you can see. Take your time to understand what’s going on there and I will help you with that. The AppDeepLinkLocalDataSource
is the source of truth in our app, that knows how to parse all supported deep links in the app. Here are a few things I want to highlight:
- I’m using
java.net.URI
instead ofandroid.net.Uri
to be able to cover the code with Junit tests and run it on the JVM. - The
AppDeepLink
is thesealed class
that implementDeepLink
interface from the Domain shared module.
sealed class AppDeepLink: DeepLink { | |
data object Unknown : AppDeepLink() | |
data object Home : AppDeepLink() | |
data class FareList(val ryderId: String) : AppDeepLink() | |
data class Confirmation( | |
val ryderId: String, | |
val fare: Fare, | |
) : AppDeepLink() | |
} |
Job Offers
So, the deep link the app knows how to work with is just a class containing the data needed to open the right screen in the correct state. It’s simple but at the same time, this is the abstraction we need to decouple logic from URLs and not spread it across the code base.
Basically, AppDeepLinkLocalDataSource
is just a parse that knows how to extract data from URLs.
There is no parsing logic for dynamic links. Let’s add it. First, we need to create another use case responsible for getting the original deep link from the dynamic one.
class GetDynamicLinkUseCase( | |
private val dynamicLinkRepository: DynamicLinkRepository, | |
) { | |
suspend operator fun invoke(uri: String): String? { | |
return dynamicLinkRepository.parseLink(uri) | |
} | |
} |
The input is dynamic links and the result is the original one as String
.
interface DynamicLinkRepository { | |
suspend fun parseLink(uri: String): String? | |
} |
The implementation of it in the app
module.
class DynamicLinkDataRepository( | |
private val dataSource: FirebaseDynamicLinkDataSource, | |
) : DynamicLinkRepository { | |
override suspend fun parseLink(uri: String): String? { | |
return dataSource.parseLink(uri) | |
} | |
} |
As you can see we have FirebaseDynamicLinkDataSource
as the component that knows how to extract the original deep link.
class FirebaseDynamicLinkDataSource( | |
private val host: String, | |
private val firebaseDynamicLinks: FirebaseDynamicLinks, | |
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO, | |
) { | |
suspend fun parseLink(uri: String): String? = withContext(backgroundDispatcher) { | |
if (uri.matchesPattern(host).not()) return@withContext null | |
try { | |
firebaseDynamicLinks.getDynamicLink(Uri.parse(uri)).await().link?.toString() | |
} catch (e: Exception) { | |
throw DynamicLinkParseException(cause = e) | |
} | |
} | |
} |
Here we delegate to the Firebase SDK parsing logic as the Firebase only knows how to do that.
The host
is the base scheme of the URL. For us it’s moove.page.link
part. if the URL doesn’t match it then we return null
because we don’t know how to work with them.
Let’s back to GetDeeplinkUseCase
and add logic with dynamic links.
class GetDeeplinkUseCase( | |
private val deeplinkRepository: DeeplinkRepository, | |
private val getDynamicLinkUseCase: GetDynamicLinkUseCase, | |
) { | |
suspend operator fun invoke(uri: String): DeepLink { | |
val parsedUri = getDynamicLinkUseCase(uri) ?: uri | |
val deepLink = deeplinkRepository.getDeepLink(parsedUri) | |
return deepLink | |
} | |
} |
By default, we assume the URL is the dynamic link and try to parse it.
- If we get the result
null
then understand that this is the deep link we already know how to parse it and pass it to the parser. - If we get the parsed link from
GetDynamicLinkUseCase
then we use it as the link we know how to work with.
So, now we defined the Data and Domain layer in deep link architecture, it’s time to move forward to the Presentation layer.
In the shared
module of the app in the Presentation layer we need to create DeepLinkNavigator
.
The navigator has only one method to execute navigation and takes as DeepLink
as input. The implementation of this navigator will be in the app
module
Here you can see different navigators. If you feel confused about what is it, I recommend you to read the previous article about the Presentation layer. the chapter about navigation.
The DeepLinkAppNavigator
contains all navigators it needs, from different feature modules in the app. For example the TicketNavigator
knows how to navigate to the ticket screens.
The TicketNavigator
the interface we keep in the shared
module
The ticket
the module depends on the shared
, we can use it for navigation though ticket
screens. Also, we have the GlobalAppNavigator
that knows how to navigate to any place in the app. It implements TicketNavigator
.
In the app
module we implement it as AppNavigator
, the instance of it we inject as dependencies following the Dependency Inversion Principle (DIP).
So, we have covered the navigator part and it’s time to look at the entry point in our app — MainActivity
. This activity will start when the user hits the URL.
class MainActivity : AppCompatActivity() { | |
private val mainViewModel: MainActivityViewModel by viewModel() | |
/* ... */ | |
override fun onCreate(savedInstanceState: Bundle?) { | |
/* ... */ | |
mainViewModel.handleIntent(intent) | |
} | |
override fun onNewIntent(intent: Intent) { | |
super.onNewIntent(intent) | |
mainViewModel.handleIntent(intent) | |
} | |
/* ... */ | |
} |
The ViewModel will extract the URL from the intent and pass it to the GetDeeLinkUseCase
.
And don’t forget to set intent-filter
in the AndroidManifest
so your app could recognize different types of deep links.
<intent-filter android:autoVerify="true"> | |
<action android:name="android.intent.action.VIEW" /> | |
<category android:name="android.intent.category.DEFAULT" /> | |
<category android:name="android.intent.category.BROWSABLE" /> | |
<!-- Supported schemes --> | |
<data android:scheme="moove" /> | |
<!-- Corporate subdomains --> | |
<data android:host="app" /> | |
</intent-filter> | |
<intent-filter android:autoVerify="true"> | |
<action android:name="android.intent.action.VIEW" /> | |
<category android:name="android.intent.category.DEFAULT" /> | |
<category android:name="android.intent.category.BROWSABLE" /> | |
<!-- Supported schemes --> | |
<data android:scheme="https" /> | |
<data android:scheme="http" /> | |
<!-- Corporate subdomains --> | |
<data android:host="moove.com" /> | |
<data android:pathPattern="/ticket/confirmation" /> | |
<data android:pathPattern="/home" /> | |
</intent-filter> | |
<intent-filter android:autoVerify="true"> | |
<action android:name="android.intent.action.VIEW" /> | |
<category android:name="android.intent.category.DEFAULT" /> | |
<category android:name="android.intent.category.BROWSABLE" /> | |
<!-- Supported schemes --> | |
<data android:scheme="https" /> | |
<data android:scheme="http" /> | |
<!-- Corporate subdomains --> | |
<data android:host="${firebaseDynamicLinkHost}" /> | |
</intent-filter> |
Finally, we have covered the main part, but there is one more thing I want to share with you.
The remote deep link parser
Imagine the case when your app doesn’t know anything about how to parse the deep link, and all this logic is on your server side. When your app gets the URL and asks the server to parse it for me. What’s the point of doing that? You can ask me.
The business often uses Web URLs as deep links too, it’s practical to have one URL to represent the same page and screen in the app. The problem comes when the business decides to do SEO optimization of your website. In the process of doing that, the URL that represents the web page can be changed. If you have all your parsing logic locally in your app, it will stop working correctly. To fix that you need to make changes in your client apps and release the new version. It’s not a flexible and easy-to-break solution.
To avoid such a situation I recommend you make your server the main source of truth, that knows how to parse any link and send the response, the client will always understand. When you need to change the web page URL, change the parsing logic on the server too, as a result, all your clients will remain working.
Wrapping up
Today, I showed you how you can add deep link support to your app using the Clean Architecture approach in your mind. Make deep link platform independent and easy to cover with tests.
You can find all the code and the sample app here.
Stay tuned for the next App Architecture topic to cover.
This article is previously published on proandroiddev.com