Introducing Deep Linking API (from the Just Eat Takeaway Android Consumer Application)
At Just Eat Takeaway we have been using the same deep-linking solution for some time and recently we have open-sourced our solution as a library available on GitHub.
This article will share some of the history and reasoning behind our approach as well as provide a practical example to demonstrate how you may use it in your applications.
Our legacy approach to deep links
Back in the day before we arrived at our current solution to deep linking in our Android consumer application we went with a simple approach that most developers tackling this issue might go for — If/Else conditions that will evaluate what the current Intent::data
(the Uri
portion of an intent) looks like and decide what to do from there.
I dug out a very early version of this code, it does not handle many Uri patterns however the actual version we did use handled many more cases.
if (targetUri.toString().contains(DeepLinkableScreens.RESTAURANT)) {
final Intent intent;
String tabName = targetUri.getLastPathSegment();
if (DeepLinkableScreens.RESTAURANT_REVIEWS.equals(tabName)
|| DeepLinkableScreens.RESTAURANT_INFO.equals(tabName)) {
intent = getRestaurantWithTabSelectedIntent(targetUri);
} else {
intent = createRestaurantIntent(targetUri);
}
result.setIntent(intent);
} else if (WEB_SCHEME.equals(scheme) || SECURE_WEB_SCHEME.equals(scheme) ) {
if (targetUri.getPath() != null &&
targetUri.getPath().contains(UPDATE_PASSWORD_LINK) &&
targetUri.isHierarchical() &&
targetUri.getQueryParameterNames().contains(TOKEN)) {
String token = targetUri.getQueryParameter(TOKEN);
result.setIntent(intentCreator.newAuthenticatorActivityWithResetToken(context, token));
} else if (isSerpPageUrl(targetUri)) {
result.setIntent(getHttpSerpDeepLinkIntent(targetUri));
} else {
result.setIntent(intentCreator.newSplashActivityIntent(context));
}
} else if (DISPATCHER_SCHEME.equals(scheme)) {
result.setIntent(getDispatcherLinkIntent(targetUri, host));
} else {
result.setIntent(getAppLinkIntent(targetUri, host));
}
You can see from the example that it’s not very complicated but quite messy, however it did the job. One of the main reasons we moved away from this approach was about scale. If you only have a handful of deep-link Uri patterns to deal with this approach can suffice, however the more patterns you start to support it can easily get out of hand.
We hit the point of what is a reasonable amount of switch case branches and decided to pursue the next-level of deep link handling. This is called Deep-Linking API and it’s a fairly simple approach based on the Front-Controller pattern.
Deep Linking API
We have been using this approach for years now and it still works well. It is based on an approach used in web systems which I first learned in Martin Fowler’s book Patterns of Enterprise Architecture and it is called the Front-Controller pattern.
The idea is you have a class (the controller or handler) that has a function that is given a Uri
and it will delegate the handling of that Uri
to a Command
object.
In our Deep Linking API we allow the mapping of Uri’s to commands using a simple function deepLinkRouter
that sets some things up for you, all you need to define is an activity that will handle your deep-links and define the mappings within it. This is a simple example.
class ExampleDeepLinkActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
deepLinkRouter {
schemes("https")
hosts("simple.site.com")
"/home" mapTo { HomeCommand() }
"/products/[a-zA-Z0-9]*" mapTo { ProductCommand() }
}.route(intent.data ?: Uri.EMPTY)
}
}
In the example above we define an activity ExampleDeepLinkActivity
overriding onCreate
, this is where we can safely define our routings (the API is backed by a ViewModel).
We code a block containing statements defining which Uri
pattern will map to which Command
.
The block starts by defining schemes
and hosts
. For our example we want to handle Uri
‘s that start with https://simple.site.com
We have two definitions, one for /home
and another for /product/[a-zA-Z0-9]*
where the latter contains a regex pattern to match any product identifier.
We use a mapTo
operator for each pattern we wish to map to a Command
.
With our block declared we can then call the function route(Uri)
passing in the intent Uri.
Internally the router will find the matching command given the Uri and ultimately execute the command.
Commands
All commands derive from the abstract Command
class. The class has a single abstract function execute()
that must be implemented. Here we can define what happens, we can redirect the user to a new activity, show a fragment or maybe a dialog, etc. It is up to you. At Just Eat Takeaway we just navigate the user to a designated screen and sometimes we may build the back stack.
Here is a simple implementation of the ProductCommand
class ProductCommand : Command() {
private val productId by pathSegment(1)
override fun execute() = navigate { context ->
context.startActivity(
Intent(context, ProductActivity::class.java)
.putExtra("productId", productId)
)
}
}
At minimum the command must call the navigate { context -> }
function with a block that will perform some logic. The reason we do it like this is we may call your navigate
function block later but only when context
is available and safe to call with, this is because the API can also do some fancy stuff with coroutines called requirements which we will explain later, long story short, we want to be sure that when it is time to navigate we have a valid context.
Looking at our ProductCommand
example we extract a productId
from the Uri
using a convenient property delegate pathSegment(Int)
that extracts a slug from the Uri at position 1. This is a useful feature that allows you to easily map parts of the Uri to properties, you can also use queryParam(String)
to extract a named query parameter from the Uri
or if that is not enough access the uri
property on the command to do some advanced work with the Uri.
The command simply navigates to another screen ProductActivity
.
Adding an intent-filter
When you handle deep-links in your app you need to define an intent-filter
in AndroidManifest.xml
, the code we looked at so far won’t work unless you do this and it is a requirement of handling deep links in android.
<activity android:name="ExampleDeepLinkActivity">
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="https" />
<data android:host="simple.site.com" />
</intent-filter>
</activity>
The example above shows how to define an intent-filter
, in the example we define an android:scheme
and an android:host
and that should be enough for the most basic setup, you may want to define additional path patterns here to make sure you don’t consume every Uri pattern the user may open on a device.
Requirements
When we designed our Deep Linking API we had many cases across our app where in order to handle a deep link we needed some prerequisite state such as being logged in, or having a geolocation, etc. We did not want to enforce this check on each of our activities so we came up with a solution in the API called requirements where we can require()
some data and later satisfy(Any)
that data in order to proceed in a command. Effectively a command can stop execution and allow the user to follow a flow (to login or geolocate, etc) then return and complete the flow (or satisfy(Any)
) with some gathered information.
Here we have another command OrderDetailsCommand
that uses require()
to require a LoginResult
.
class OrderDetailsCommand : Command() {
private val orderId by pathSegment(1)
private var loginResult: LoginResult? = null
override fun execute() {
launch {
loginResult = require()
}
navigate { context ->
context.startActivity(
Intent(context, OrderDetailsActivity::class.java)
.putExtra("orderId", orderId)
.putExtra("loginName", loginResult!!.name)
)
}
}
}
In this command when we hit the line loginResult = require()
execution will suspend until the requirement has been satisfied with satisfy(Any)
.
In order to define what happens when we hit this line we need to implement a callback on the router onRequirement(..)
that will kick off a UI flow to log the user in.
class ExampleDeepLinkActivity : ComponentActivity() {
private val router by lazy {
deepLinkRouter {
schemes("https")
hosts("requirements.site.com")
"/home" mapTo { HomeCommand() }
"/orders/[a-zA-Z0-9]*" mapTo { OrderDetailsCommand() }
}
}
private val loginForResult = registerForActivityResult(StartActivityForResult()) {
val loginName = it.data!!.getStringExtra("loginName")!!
router.satisfy(LoginResult(name = loginName))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
router.onRequirement(this) {
if (it == LoginResult::class.java) {
startLoginActivity()
}
}
router.route(intent.data ?: Uri.EMPTY)
}
private fun startLoginActivity() {
loginForResult.launch(Intent(this, LoginActivity::class.java))
}
}
The ExampleDeepLinkActivity
is similar to the previous one however we have moved our deepLinkRouter
definition into a lazy property since we need to call an extra function to handle requirements.
router.onRequirement(this) {
if (it == LoginResult::class.java) {
startLoginActivity()
}
}
In the example we can check the type of the currently required object represented as it
and if it is of type LoginResult
we kick off the login flow with startLoginActivity()
.
Later once the user is done with the flow we can satisfy the requirement which will then allow the command to continue execution. In our example we use the Activity Result API to demonstrate this.
private val loginForResult = registerForActivityResult(StartActivityForResult()) {
val loginName = it.data!!.getStringExtra("loginName")!!
router.satisfy(LoginResult(name = loginName))
}
When we receive an activity result callback we extract any necessary data and call router.satisfy(LoginResult(name = loginName))
which completes the require/satisfy roundtrip.
You can require
more than one thing and the command will happily wait until needs are satisfied. This does introduce the risk that you may accidentally never call satisfy
which is something you need to be aware of and handle gracefully.
Looking back at our command we can finally proceed to navigate (which can use the required data when navigating).
navigate { context ->
context.startActivity(
Intent(context, OrderDetailsActivity::class.java)
.putExtra("orderId", orderId)
.putExtra("loginName", loginResult!!.name)
)
}
Conclusion
The Deep Linking API can help you handle deep links in an organised manner by encapsulating navigation logic into separate command classes. It can make it a simple task to define which Uri’s map to those commands using the mapTo
operator in your routing definition. Also the API goes deeper than just calling deepLinkRouter
, if you check the code you can see it helps you setup a DeepLinkRouterController
which has more capability for advanced scenarios when integrating into more complex systems than the examples in this article provide.
We urge you to try it out and give us feedback. Let us know what you think, if it solves your problem of deep-linking or is lacking in support for your particular scenario and of course raise PR’s if you have something you think should be changed.
References
- Front-Controller Pattern https://martinfowler.com/eaaCatalog/frontController.html
- Deep Linking API https://github.com/justeattakeaway/android-deep-links
- Android Docs https://developer.android.com/training/app-links