Deep Linking API

Additional

Language
Kotlin
Version
1.0.0 (Sep 9, 2022)
Created
Jul 28, 2022
Updated
Sep 9, 2022 (Retired)
Owner
Just Eat Takeaway.com (justeattakeaway)
Contributor
Ian Warwick (fluxtah)
1
Activity
Badge
Generate
Download
Source code

Deep Linking API

In an Android app we can define URI patterns that map URI's to activities using intent-filters.

To find out more about this you should read this documentation provided by Google https://developer.android.com/training/app-links

Once we have defined which URI's are handled by our application in our manifest we can then inspect the URI of incoming link Intent's and decide how to act on them.

The Deep Linking API takes care of handling incoming links by mapping URI patterns to Command's.

These Command's can then be used to launch an activity, show a Fragment, show some other UI or anything else you can do in the context of the Activity that handles your deep links.

The approach is inspired by Martin Fowler's Front Controller pattern.

Adding Dependencies

Step 1. Add the JitPack repository to your build file

allprojects {
  repositories {
    ...
    maven { url 'https://jitpack.io' }
  }
}

Step 2. Add the dependency

dependencies {
  implementation 'com.github.justeattakeaway:android-deep-links:1.0.0'
}

Usage Guide

We first must designate an activity that will handle incoming deep links and add the necessary intent-filters for the activity in our AndroidManifest.xml as follows.

<activity android:name=".examples.simple.ExampleDeepLinkActivity" android:excludeFromRecents="true"
    android:exported="true" android:launchMode="singleTask">
    <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>

With our intent-filter defined we can then define our routing, for the simplest approach we use deepLinkRouter extension function to do most of the setup for us.

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 we map paths /home to a HomeCommand and also a /product/[a-zA-Z0-9]* to a ProductCommand respectively.

The path part is what follows the host part of the URI such as https://simple.site.com/products/123 and in our mapping we define it as a Regex.

For the ProductCommand we map it to a Regex that matches a product ID of the pattern [a-zA-Z0-9]*

Commands

With our mapping defined and mapped to commands we need to make our commands do something.

Typically a command will just start an activity however it can do more (more on this later).

The following command is for the /home path pattern.

class HomeCommand : Command() {
    override fun execute() = navigate { context ->
        context.startActivity(Intent(context, HomeActivity::class.java))
    }
}

Looking at the HomeCommand example notice the block navigate { .. }.

Commands should end with a navigate {} block and the block should define what should happen once the command is complete. The reason for this is commands can work with coroutines (more on this later) and sometimes a command may take longer to complete and also go through Android configuration changes. The navigate {} block will be called at a moment where its safe to do so in the android UI lifecycle and given the current Context so that it may safely perform things like intent navigation.

Other than the lengthy explanation for the navigate block, a command is mostly simple and all it does is redirect to an activity, in this case HomeActivity.

The next example is the ProductCommand that is mapped to the pattern /products/[a-zA-Z0-9]*

class ProductCommand : Command() {
    private val productId by pathSegment(1)

    override fun execute() = navigate { context ->
        context.startActivity(
            Intent(context, ProductActivity::class.java)
                .putExtra("productId", productId)
        )
    }
}

This command extracts a path segment from position 1 in the URI which is the part that matched [a-zA-Z0-9]* giving us the product ID. We achieve this using the convenient pathSegment(index) property delegate.

As with pathSegment(index) we can also use queryParam(name) to get at the URI's query parameters, if that is not enough you can access a property uri which will give you the android.net.Uri.

The ProductCommand concludes with navigate { } constructing an Intent for ProductActivity passing along the product ID extracted from the Uri as an intent extra.

Testing your deep links

To test links you can use an ADB shell command to launch your app and give it a link, the following example shows how to launch with a link that maps to HomeCommand.

adb shell am start -W -a android.intent.action.VIEW -d "https://simple.site.com/home" com.jet.android.links

In the command we specify which app to launch using the package name com.jet.android.links.

You can read more about this in the official Android developer docs https://developer.android.com/training/app-links/deep-linking#testing-filters

Similarly to map to ProductCommand we can use the URI pattern with the product id as follows.

adb shell am start -W -a android.intent.action.VIEW -d "https://simple.site.com/products/abcd1234" com.jet.android.links

Command Requirements

Intercepting a deep link and handing it in a command is useful, we can inspect the deep link and route it into the app to an activity or other. Sometimes however we may need more information from the user that is deep-linking into the app or we may require them to satisfy a particular state such as being authenticated or being geo located.

To handle these situations we can use Command Requirements, a neat way to suspend a command until the requirements are satisfied.

The following example shows how to achieve this using the linking API's require() and satisfy(Any) functions.

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 the above command when we hit the line loginResult = require() our command will suspend and wait for the value from require().

To make the command continue we need to tell the router/controller to satisfy(Any) the requirement.

The following example shows a deep link router setup that maps an incoming deep link with the path pattern /orders/[a-zA-Z0-9]* to the OrderDertailsCommand. This will match on a deep link such as https://requirements.site.com/orders/abcd1234

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))
    }
}

We use the Activity Result API to launch a new activity LoginActivity, the user enters their name and returns back with a Login button. When the user returns back to ExampleDeepLinkActivity we extract the loginName argument from the result Intent (the name they entered into the name field on login screen) and we then call router.satisfy(LoginResult(name = loginName)) passing in the login name.

In order to launch the LoginActivity we need to tell the router what to do when a command comes across a requirement

router.onRequirement(this) {
    if (it == LoginResult::class.java) {
        startLoginActivity()
    }
}

We achieve this by calling onRequirement and if we have test for the requirement for a LoginResult and if true then start the login activity using startLoginActivity() which simply launches the LoginActivity.

Command Completion

When a command completes, the default behaviour when using deepLinkRouter extension function to set up a router will call the commands navigate(Context) function and then call finish() on the activity.

If you want to do something different you can provide your own command completion callback.

router.onCommandComplete(this) {
    when (it) {
        is DeepLinkRouter.Result.Complete -> {
            // TODO do something before navigate
            it.navigate(this)
            // TODO do something after navigate
            finish()
        }
        is DeepLinkRouter.Result.Cancelled -> {
            // TODO handle command cancellation
        }
    }
}

Looking at the example we call onCommpandComplete(LifecycleOwner, (DeepLinkRouter.Result) -> Unit) with a callback that can handle the result and then must call it.navigate(this) to execute the commands navigate function manually. You can then either finish() the activity (the usual pattern) or do something else.

As well as handling command completion we can also define what happens when the command is cancelled, this will occur if the commands coroutine Job is cancelled.

References

LICENSE

Copyright 2022 Just Eat Takeaway

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.