Deep linking in Android the easy way

Introduction

Deep linking in Android serves as a useful way for other apps, services or web pages to open relevant content within your app. It’s particularly useful if, like JUST EAT, you have a website that hands off content to the app, or for Google App Indexing. App Indexing allows Google searches to deep link straight into the app activities that you expose. It’s easy to implement, but it’s also easy to go overboard and make a mess of your, otherwise pretty, code. Here we present a simple way to structure your code so that it’s headache free and, more importantly, unit-testable.

Study case

Let’s say we have three activities, A, B and C. A opens B and B opens C.
We need to support deep links to each of these activities – both custom schema URIs and web URIs.
Activity B needs one argument to work properly, whereas activity C needs two. Activity A needs no arguments. When switching between the activities, arguments are passed between them using the standard Intent Mechanism. The deep links will contain the same arguments in a bespoke format.
Project structure
Deep links that need to be supported include:
Activity A:

example-scheme://activitya
http(s)://(www.)example.co.uk/a

Activity B:

example-scheme://activityb?query=[text]

Activity C:

example-scheme://activityc?query=[text]&choice=[int]
http(s)://(www.)example.co.uk/c?query=[text]&choice=[int]

The don’ts

Let’s see what a typical setup for this scenario might look like. Let’s start with the Manifest.

<activity
    android:name=".activities.AActivity"
    android:label="@string/label_a" >
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:scheme="example-scheme"/>
        <data android:host="activitya"/>
        <data android:pathPattern="/.*"/>
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:scheme="http" />
        <data android:scheme="https"/>
        <data android:host="www.example.co.uk" />
        <data android:host="example.co.uk" />
        <data android:path="/a" />
    </intent-filter>
</activity>
<activity
    android:name=".activities.BActivity"
    android:label="@string/label_b" >
    <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:scheme="example-scheme"/>
        <data android:host="activityb"/>
        <data android:pathPattern="/.*"/>
    </intent-filter>
</activity>
<activity
    android:name=".activities.CActivity"
    android:label="@string/label_c" >
    <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:scheme="example-scheme"/>
        <data android:host="activityc"/>
        <data android:pathPattern=".*"/>
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:scheme="http" />
        <data android:scheme="https"/>
        <data android:host="www.example.co.uk" />
        <data android:host="example.co.uk" />
        <data android:path="/c" />
    </intent-filter>
</activity>

We have just three activities, but our manifest is already bloated and unreadable because of all the intent filters! This is not the end of the bad news though… let’s look at the code needed to parse the deep links – let’s take activity B as an example.

private void parseIntent() {
    final Intent intent = getIntent();
    final Uri uri = intent.getData();
    if (uri != null) {
        if ("example-scheme".equals(uri.getScheme()) && "activityb".equals(uri.getHost())) {
            // Cool, we have a URI addressed to this activity!
            mQuery = uri.getQueryParameter("query");
        }
    }
    if (mQuery == null) {
        mQuery = intent.getStringExtra(IntentHelper.EXTRA_B_QUERY);
    }
}

There’s nothing fundamentally wrong with this code, but it’s not unit-testable – integration tests would work fine, but why on earth would we need to fire up the device emulator just to test that our deep links are handled? While moving this code out to a helper class would solve this problem, we would now have a sprinkling of helper classes for all deep-linkable activities, where some of the code is repetitive. Imagine having to implement 20 different deep links with varying amounts of parameters to extract – not a great prospect.

Back to the drawing board

What can we change here then? How about using a single activity to dispatch deep links to all the others?
How is it going to help? What are the benefits?

  • All the intent filters collapse nicely into one activity tag
  • We can handle all deep links in one place – a parser class instantiated in our dispatcher activity.
  • The parser class is easily unit-testable
  • The logic is deduplicated and simplified
  • We can pass the arguments to activities on the intent we use to launch the target activity, adding no additional logic to the target activity

 
The full solution will be presented here step by step, including how to set up unit tests for our deep links.

The Manifest

This time, all of our manifest logic is contained to one activity tag. This is what it looks like…

    <activity
        android:name=".activities.LinkDispatcherActivity"
        android:noHistory="true"
        android:launchMode="singleInstance"
        android:theme="@android:style/Theme.NoDisplay" >
        <intent-filter>
            <action android:name="android.intent.action.VIEW"/>
            <category android:name="android.intent.category.DEFAULT"/>
            <category android:name="android.intent.category.BROWSABLE"/>
            <data android:scheme="example-scheme"/>
            <data android:host="activitya"/>
            <data android:host="activityb"/>
            <data android:host="activityc"/>
            <data android:pathPattern=".*"/>
        </intent-filter>
        <intent-filter>
            <action android:name="android.intent.action.VIEW"/>
            <category android:name="android.intent.category.DEFAULT"/>
            <category android:name="android.intent.category.BROWSABLE"/>
            <data android:scheme="http" />
            <data android:scheme="https"/>
            <data android:host="www.example.co.uk" />
            <data android:host="example.co.uk" />
            <data android:path="/a" />
            <data android:path="/c" />
        </intent-filter>
    </activity>

In addition to cutting down on some code, this setup makes it much clearer what is actually going on here. As you can see we can specify multiple hosts or paths, either of which will result in a hit on the respective Intent filter. All of our deep link Intents will go to our LinkDispatcherActivity, so they can be processed and forwarded to the actual activities responsible for a link.

    <activity
        android:name=".activities.LinkDispatcherActivity"
        android:noHistory="true"
        android:launchMode="singleInstance"
        android:theme="@android:style/Theme.NoDisplay" >

A few important things to note about the above piece of code. The noHistory attribute will prevent the activity from appearing in the back stack. Once it’s finished, it’s gone and the user won’t be able to return to it. The theme set on this activity will make it completely transparent to the user.
The launchMode is an interesting and important one. The singleInstance mode we specified makes the LinkDispatcherActivity launch in its own exclusive task. Then any activity started from this activity launches in a separate task. What this means in practice is that we avoid problems arising from already having an existing application task. The deep link will launch its own task with its own back stack instead of plugging into an existing one. This also satisfies one of the Google App Indexing requirements – we want the back button to back out to the activity that launched the deep link instead of back down an existing back stack.

The code

The entirety of the deep link handling code resides in two simple classes. The activities are unaffected – they don’t know whether they were launched from a deep link or a regular Intent, as we take advantage of the code that’s already there – parsing Intent extras. What this means in practice is that we need a class that can take a URI, parse it, and turn it into an Intent that we subsequently start.

public class LinkDispatcherActivity extends Activity {
    private final UriToIntentMapper mMapper = new UriToIntentMapper(this, new IntentHelper());
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        try {
            mMapper.dispatchIntent(getIntent());
        } catch (IllegalArgumentException iae) {
            // Malformed URL
            if (BuildConfig.DEBUG) {
                Log.e("Deep links", "Invalid URI", iae);
            }
        } finally {
            // Always finish the activity so that it doesn't stay in our history
            finish();
        }
    }
}

The activity itself, as you can see, doesn’t do much. That is because we wish the URI-to-Intent mapping logic to be fully unit-testable. Admittedly you could use a service (not recommended by Google, but safe in our particular case) instead of an activity here, but we found this works well.
The UriToIntentMapper contains all of the mapping logic we are interested in. Below is the core code for supporting our deep links.

public class UriToIntentMapper {
    private Context mContext;
    private IntentHelper mIntents;
    public UriToIntentMapper(Context context, IntentHelper intentHelper) {
        mContext = context;
        mIntents = intentHelper;
    }
    public void dispatchIntent(Intent intent) {
        final Uri uri = intent.getData();
        Intent dispatchIntent = null;
        if (uri == null) throw new IllegalArgumentException("Uri cannot be null");
        final String scheme = uri.getScheme().toLowerCase();
        final String host = uri.getHost().toLowerCase();
        if ("example-scheme".equals(scheme)) {
            dispatchIntent = mapAppLink(uri);
        } else if (("http".equals(scheme) || "https".equals(scheme)) &&
                ("www.example.co.uk".equals(host) || "example.co.uk".equals(host))) {
            dispatchIntent = mapWebLink(uri);
        }
        if (dispatchIntent != null) {
            mContext.startActivity(dispatchIntent);
        }
    }
    private Intent mapAppLink(Uri uri) {
        final String host = uri.getHost().toLowerCase();
        switch (host) {
            case "activitya":
                return mIntents.newAActivityIntent(mContext);
            case "activityb":
                String bQuery = uri.getQueryParameter("query");
                return mIntents.newBActivityIntent(mContext, bQuery);
            case "activityc":
                String cQuery = uri.getQueryParameter("query");
                int choice = Integer.parseInt(uri.getQueryParameter("choice"));
                return mIntents.newCActivityIntent(mContext, cQuery, choice);
        }
        return null;
    }
    private Intent mapWebLink(Uri uri) {
        final String path = uri.getPath();
        switch (path) {
            case "/a":
                return mIntents.newAActivityIntent(mContext);
            case "/c":
                String cQuery = uri.getQueryParameter("query");
                int choice = Integer.parseInt(uri.getQueryParameter("choice"));
                return mIntents.newCActivityIntent(mContext, cQuery, choice);
        }
        return null;
    }
}

This is where all the magic happens. We’ve basically moved all logic involved in parsing deep link Intents from all the activities here. Simple. You might say that we should use resources for our string constants here. I personally think for simplicity’s sake this is better and hardly has any memory impact. I should probably mention the IntentHelper class now to make it all clearer…

public class IntentHelper {
    public static String EXTRA_B_QUERY = "com.justeat.app.deeplinks.intents.Intents.EXTRA_B_QUERY";
    public static String EXTRA_C_QUERY = "com.justeat.app.deeplinks.intents.Intents.EXTRA_C_QUERY";
    public static String EXTRA_C_CHOICE = "com.justeat.app.deeplinks.intents.Intents.EXTRA_C_CHOICE";
    public Intent newAActivityIntent(Context context) {
        Intent i = new Intent(context, AActivity.class);
        i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
        return i;
    }
    public Intent newBActivityIntent(Context context, String query) {
        Intent i = new Intent(context, BActivity.class);
        i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
        i.putExtra(EXTRA_B_QUERY, query);
        return i;
    }
    public Intent newCActivityIntent(Context context, String query, int choice) {
        Intent i = new Intent(context, CActivity.class);
        i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
        i.putExtra(EXTRA_C_QUERY, query);
        i.putExtra(EXTRA_C_CHOICE, choice);
        return i;
    }
}

As you can see, it’s really simple. This class allows us to reuse code for constructing Intents. The activities use these internally between each other and we can use the exact same code when dispatching our deep links. This means no additional work on activities, as they should already handle the Intent extras.

Testing – manual testing

There’s a great little tool created by Google to help us test our deep links. It’s simple to use, just replace the example information with your own. In our case an example is:

android-app://com.justeat.app.deeplinks/example-scheme/activityb?query=abcd123

After you’re done, just use a QR code reader to test your deep link.

Testing – Intent filters

One of the things we have to test is our intent filter. We can write tests that check whether our URIs resolve to the LinkDispatcherActivity. For the sake of brevity, I won’t present the entire test suite here, the entire code is hosted on GitHub if you wish to take a look. Some of the code for matching Intent filters was taken from the fantastic Robolectric project and adapted to fit our testing purpose.

@Config(constants = BuildConfig.class, sdk = 21)
@RunWith(RobolectricRunner.class)
public class IntentFilterTest {
    @Test
    public void activity_a_app_link_resolves() {
        assertIntentResolves(DeepLinks.getActivityAUri());
    }
    @Test
    public void activity_b_app_link_resolves() {
        assertIntentResolves(DeepLinks.getActivityBUri("bogoQuery"));
    }
    private void assertIntentResolves(String uriString) {
        //Arrange
        ShadowApplication.getInstance().getAppManifest();
        RobolectricPackageManager packageManager = (RobolectricPackageManager) RuntimeEnvironment.application.getPackageManager();
        packageManager.setQueryIntentImplicitly(true);
        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uriString));
        //Act
        List resolveInfo = queryImplicitIntent(intent, -1);
        ArrayList activityNames = new ArrayList<>();
        for (ResolveInfo info : resolveInfo) {
            activityNames.add(info.activityInfo.targetActivity);
        }
        //Assert
        assertThat(LinkDispatcherActivity.class.getName(), isOneOf(activityNames.toArray()));
    }
    private List queryImplicitIntent(Intent intent, int flags) {
        List resolveInfoList = new ArrayList();
        AndroidManifest appManifest = ShadowApplication.getInstance().getAppManifest();
        String packageName = appManifest.getPackageName();
        for (Map.Entry<String, ActivityData> activity : appManifest.getActivityDatas().entrySet()) {
            String activityName = activity.getKey();
            ActivityData activityData = activity.getValue();
            if (activityData.getTargetActivity() != null) {
                activityName = activityData.getTargetActivityName();
            }
            if (matchIntentFilter(activityData, intent)) {
                ResolveInfo resolveInfo = new ResolveInfo();
                resolveInfo.resolvePackageName = packageName;
                resolveInfo.activityInfo = new ActivityInfo();
                resolveInfo.activityInfo.targetActivity = activityName;
                resolveInfoList.add(resolveInfo);
            }
        }
        return resolveInfoList;
    }
    private boolean matchIntentFilter(ActivityData activityData, Intent intent) {
        System.out.println("Searching for "+ intent.getDataString());
        for (IntentFilterData intentFilterData : activityData.getIntentFilters()) {
            List actionList = intentFilterData.getActions();
            List categoryList = intentFilterData.getCategories();
            IntentFilter intentFilter = new IntentFilter();
            for (String action : actionList) {
                intentFilter.addAction(action);
            }
            for (String category : categoryList) {
                intentFilter.addCategory(category);
            }
            for (String scheme : intentFilterData.getSchemes()) {
                intentFilter.addDataScheme(scheme);
            }
            for (String mimeType : intentFilterData.getMimeTypes()) {
                try {
                    intentFilter.addDataType(mimeType);
                } catch (IntentFilter.MalformedMimeTypeException ex) {
                    throw new RuntimeException(ex);
                }
            }
            for (String path : intentFilterData.getPaths()) {
                intentFilter.addDataPath(path, PatternMatcher.PATTERN_LITERAL);
            }
            for (String pathPattern : intentFilterData.getPathPatterns()) {
                intentFilter.addDataPath(pathPattern, PatternMatcher.PATTERN_SIMPLE_GLOB);
            }
            for (String pathPrefix : intentFilterData.getPathPrefixes()) {
                intentFilter.addDataPath(pathPrefix, PatternMatcher.PATTERN_PREFIX);
            }
            for (IntentFilterData.DataAuthority authority : intentFilterData.getAuthorities()) {
                intentFilter.addDataAuthority(authority.getHost(), authority.getPort());
            }
            // match action
            boolean matchActionResult = intentFilter.matchAction(intent.getAction());
            // match category
            String matchCategoriesResult = intentFilter.matchCategories(intent.getCategories());
            // match data
            int matchResult = intentFilter.matchData(intent.getType(), intent.getScheme(),
                    intent.getData());
            if ((matchResult != IntentFilter.NO_MATCH_DATA && matchResult != IntentFilter.NO_MATCH_TYPE)) {
                System.out.println("Matcher result for " + intent.getType() + " " + intent.getScheme() + " " +
                        intent.getDataString() +": " + Integer.toHexString(matchResult));
                System.out.println(intentFilterData.getSchemes());
                for (IntentFilterData.DataAuthority authority : intentFilterData.getAuthorities()) {
                    System.out.println(authority.getHost() + ":" + authority.getPort());
                }
                System.out.println(intentFilterData.getPaths());
                System.out.println(intentFilterData.getPathPatterns());
                System.out.println(intentFilterData.getPathPrefixes());
                boolean pathMatchesExactly = false;
                for (int i = 0; i < intentFilter.countDataPaths(); i++) {
                    PatternMatcher pm = intentFilter.getDataPath(i);
                    if (pm.match(intent.getData().getPath())) {
                        System.out.println("Pattern match on path: " + pm.getPath());
                    }
                }
            }
            // Check if it's a match at all
            boolean result = matchActionResult && (matchCategoriesResult == null) &&
                    (matchResult != IntentFilter.NO_MATCH_DATA && matchResult != IntentFilter.NO_MATCH_TYPE);
            // No match, discard activity
            if (!result) continue;
            // We have a partial match. The matchResult doesn't seem to correspond to reality
            // as far as path matching goes – no idea why. Fortunately we can check if the path
            // matches exactly manually.
            if (!TextUtils.isEmpty(intent.getData().getPath())) {
                //If the path is not empty, we need to make sure it's an exact match
                boolean pathMatch = false;
                for (int i = 0; i < intentFilter.countDataPaths(); i++) {
                    PatternMatcher pm = intentFilter.getDataPath(i);
                    if (pm.match(intent.getData().getPath())) {
                        // Exact match found, return
                        pathMatch = true;
                    }
                }
                // No exact match found – we only have a partial match on the intent filter.
                // While this filter may work for general cases,
                // for links like "android-app://com.justeat.app.uk/just-eat.co.uk/account"
                // to work, the path must be an exact match. Hence we enforce this for all intents
                // This way we can catch all errors.
                result = pathMatch;
            }
            if (result) return true;
        }
        return false;
    }
}
public class DeepLinks {
    public static String getActivityAUri() {
        return "example-scheme://activitya";
    }
    public static String getActivityBUri(String query) {
        return String.format("example-scheme://activityb?query=%s", query);
    }
    public static String getActivityCUri(String query, int choice) {
        return String.format("example-scheme://activityc?query=%s&choice=%d", query, choice);
    }
    public static String getActivityAWebUrl(boolean https, boolean www) {
        StringBuilder sb = new StringBuilder(https ? "https" : "http");
        sb.append("://");
        if (www) sb.append("www.");
        sb.append("example.co.uk");
        sb.append("/a");
        return sb.toString();
    }
    public static String getActivityCWebUrl(boolean https, boolean www, String query, int choice) {
        StringBuilder sb = new StringBuilder(https ? "https" : "http");
        sb.append("://");
        if (www) sb.append("www.");
        sb.append("example.co.uk");
        sb.append(String.format("/c?query=%s&choice=%d", query, choice));
        return sb.toString();
    }
}

As you can see, what we do is we parse the Intent filters from the manifest and match the URI against them. We have to make sure we check for exact matches on the Intent filter (all Intent filter elements match – scheme, host, path etc.). This way we can be certain our deep link URIs resolve correctly to our LinkDispatcherActivity.

Testing – Deep link dispatching

To have end-to-end coverage of our solution we need to test whether valid URIs dispatch to corresponding activities with the correct data. To achieve that, we need to write a few simple tests around that. Below I’ll present a few example tests. Again, the full suite is presented in our GitHub repository.

@Config(constants = BuildConfig.class, sdk = 21)
@RunWith(RobolectricRunner.class)
public class DeepLinkResolutionTest {
    private IntentHelper mMockIntents;
    private Activity mMockActivity;
    private UriToIntentMapper mMapper;
    @Before
    public void setUp() {
        mMockIntents = mock(IntentHelper.class);
        mMockActivity = mock(LinkDispatcherActivity.class);
        mMapper = new UriToIntentMapper(mMockActivity, mMockIntents);
    }
    @Test
    public void activity_a_link_dispatches() {
        //Arrange
        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(DeepLinks.getActivityAUri()));
        Intent mockDispatchIntent = mock(Intent.class);
        when(mMockIntents.newAActivityIntent(mMockActivity)).thenReturn(mockDispatchIntent);
        //Act
        mMapper.dispatchIntent(intent);
        //Assert
        verify(mMockActivity).startActivity(eq(mockDispatchIntent));
    }
    @Test
    public void activity_b_link_dispatches() {
        //Arrange
        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(DeepLinks.getActivityBUri("bogo")));
        Intent mockDispatchIntent = mock(Intent.class);
        when(mMockIntents.newBActivityIntent(eq(mMockActivity), eq("bogo"))).thenReturn(mockDispatchIntent);
        //Act
        mMapper.dispatchIntent(intent);
        //Assert
        verify(mMockIntents).newBActivityIntent(any(Context.class), eq("bogo"));
        verify(mMockActivity).startActivity(eq(mockDispatchIntent));
    }
}

Conclusion

And there you have it – a simple and unit testable way of dealing with deep links. Your code is kept unblemished, thanks to the logic being moved away from activities into simple, single purpose classes. The entire working solution is available for download from our GitHub repository – we encourage you to take a look. It doubles up as a good example of how to set up your unit tests… #minifistpump

  1. I liked this approach of handling all the intents and deeplinks via single place and it’s good for maintenance and readabilility and I’m thinking of using this approach along with a library called deeplink dispatch. But I have a following scenario, please suggest some solution for this:-
    I want to use deeplink in my app show that users can browse shared content. But my api needs user to be signed in, in order to access the api. If the user is not logged in then he/she can’t access the page and shows a blank page and can’t browse app properly. If user is not logged in, s/he should be redirected to loginActivity and after sign in app should show the intended activity from schema url.
    Thanks.

    1. I think you got it right. Basically you need to “redirect” the user to the login activity before showing the content.
      You have multiple options here – you could fire off another implicit intent to view the URL you intended to after you log in. Personally I like to be more explicit, so here’s an idea:
      • When you capture the link create two Intents: one for the login screen, the other for the screen the user wants to see.
      • Add the content screen intent as an extra to the login activity intent (Intents are Parcelable – handy!).
      • Make the Login activity launch this “pending” content Intent once the user has successfully logged in.
      Hope this helps!

  2. I was doing it this way , however it is great how you are using helpers to make it more clear.
    I would recommend using the IntentHelper methods as static; I didin’t get the need of creating an instance for calling the methods, as they are only builder methods that could be made static.
    Nice to learn how to use automatic testing!

Comments are closed.