Introducing Deep Linking API (from the Just Eat Takeaway Android Consumer Application)

Ian Warwick
Just Eat Takeaway-tech
7 min readOct 12, 2022

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

--

--