At Q42 we are developing and maintaining the parcel tracking app for Dutch postal company PostNL. To us, this app seemed a perfect fit for a Android Instant App. Instant apps open by just clicking on a link on a Android device. Taking away the hurdle of installing an app but offering a native-like experience nonetheless, instant apps increase engagement with end users and boost installs of the actual app. By creating the PostNL instant app, we were able to grow the amount of installs with a whopping 20%. In this article, we’ll explain how we achieved that and what hurdles we encountered while adding an instant app to a five-year-old codebase.

A mature app

Most users enter the PostNL app through a link in an email, which points them to the details of their parcel delivery. Once on the mobile website, users see a large banner prompting them to install the app. With an instant app, though, we could skip this detour and get a user into the real app right away. Therefore, we secretly started tinkering in our biweekly free afternoon and then convinced our product owner with our prototype that an instant app was worth investing in.

At the time we started, the PostNL app was already five years old. Like many other apps, it consisted of a single module named ‘app’. The app itself was about 9 MB at the time, which was way larger than the 4 MB limit that Google allows for instant apps. If we wanted to create an instant app, it was clear that we had to split up that single module to be able to ship only a part of it as an instant app.

Since the entire app was one module, everything was set up in a way that every class had access to every other class. Best practices like using Service Interfaces or Dependency Injection were absent. Services were accessed by referring to a static instance that contained all service objects, like in the code below:

override fun onCreate(savedInstanceState: Bundle?) {
    val shipment = App.instance.mailboxService.getShipment()
}

This way of accessing services was heavily used in the codebase. Since we cannot assume that every service is always available in our multi-module setup, we needed some other form of resolving our services. So we decided our first step needed to be to implement dependency injection using Dagger.

Dagger

We began by creating a Services module, that contained all services that the app would use. They would be injected into the depending parts of the app using Dagger.

After the Dagger integration, we we able to move themes, Activity and Fragment superclasses and styles to a Base module. This is done, so we can reuse generic components in all our feature modules.

From here on out we could also separate the so called ShipmentDetail screens from all other features. This is because the Shipment Detail part of the app is the primary feature we want to expose in our instant app. Finally we ended up with a module dependency setup like this:

The primary use of the modules was as follows:

  • Services contains all the communication to the API.
  • Base contains the application class, as well as Android view code like themes and styles.
  • ShipmentDetail contains the logic for viewing a single shipment, i.e. the feature we want to enable in the instant app.
  • Features contains the rest of the logic of the app.
  • Installed wires navigation between modules together, only used in our installed regular app.
  • Instant is an empty shell referencing the modules we wanted to include in our Instant App. This was not working yet, but a preparation for the next step.

Within four weeks, and by a single developer, the entire app was refactored into a modular design and was ready to deploy. Considering the amount of legacy code, we were quite happy with the results and the time it took us to get to a deployable and tested build. We decided to focus first on getting the regular app stable so we could work on regular features. Therefore, releasing this major refactor/re-architecture quickly was important.

The release diff looked like this 😅:

After a thorough regression test we were confident in releasing this change. But to be sure we actively monitored Crashlytics. After the release, we focussed on creating the instant app itself.

Creating the instant app

After we released our refactored modular app, we could start creating the specific screens for the instant app. This was quite straightforward and not the most interesting part of the journey. We already had most of the UI we wanted in our instant app in our regular app. So we just moved them to the Feature module named ShipmentDetail and adjusted the UI a little bit using the InstantApps.isInstantApp(context) check.

We didn’t run into any problems or things worth mentioning. There are other articles out there that tell you how to differentiate between the instant and installed app. Below are a few things that are good to know when creating an instant app.

Custom url scheme

Your instant enabled modules cannot include intent filters that contain custom scheme urls. So urls like ‘myawesomeapp://myfeature’ are not allowed, only web urls (https) are allowed.

Exported

If you want a super awesome upgrade flow from instant app to installed app, you should make the activities that contain a download button exported=”true” in the AndroidManifest.xml. This way, Android can start that intent after the installation process and your app can take it from there.

Start screen

Every (instant) app needs a start screen. But since our use case is only for a shipment tracking screen, we didn’t really know what to use a home screen. Some launchers save the instant app as well, so you can come back to it later. Other apps (Vimeo or news apps) that have a similar use case (only a detail screen) solve this with a screen that shows a few new videos or articles. But of course, we couldn’t do that either… 🤔

So, we ended up building a specific screen just for the instant app with some info about the parcel tracking service, an upgrade button and a shipment search feature (borrowed from the full app). This functions as the home screen. We reckon that not a lot of users will ever see this screen, but it’s there.

The first instant deploy 🚀

Finally we were ready and about to deploy the instant app! It already worked on the Internal Test Track, so it was just a matter of promoting it to production. But to our surprise we got prompted with the following:

We checked everything a dozen times over but couldn’t find the issue. So we raised a ticket with Google Play support. After quite some time and emails explaining our situation the response was basically that “... our team isn’t trained to provide technical support for app development questions”. 🤷‍♂️

Fortunately, not long after, PostNL was approached with an unrelated offer by Google: “... reach out and offer a personalized review of features that might help you further grow with Google Play”.

So we decided to ask this department for help on our instant app deployment issue. After another three months of sending Manifest files back and forward without any results, we decided to start using Dynamic feature modules with Android App Bundles.

Switching to dynamic feature modules

Since App Bundles combine an instant app together with a regular app, this sounded related to the deployment process. App Bundles would enable us to deploy both with just one build and would decrease the total size of our app. This alone was already worth it for us!

The app was already divided into modules and using Dagger injection, so migrating to dynamic features surely wouldn’t be an issue, right? Well, not exactly, with dynamic features the dependency tree differs, so our intermodule communication through the ‘Installed’ module was obsolete. Also, our dependency injection would need to be reworked.

To illustrate, below is the new module dependency graph:

The main goal of each module remained the same. Only our former ‘Base’ module was renamed to ‘App’ to make things more clear.

Intermodule navigation

Our setup became easier, but in our case we have to navigate between feature modules. Since there is no explicit reference between modules, there is no way to start an intent referencing the Activity class directly. To resolve this, we decided to create a Navigation sealed class in the App module so we can have typed navigation between modules. It looks like this:

sealed class Navigation {
    class ShipmentDetail(myShipment: Shipment : Navigation() {
        override fun getIntent() : Intent {           
            return Intent().setClassName(
                BuildConfig.APPLICATION_ID,
                "com.example.ShipmentDetailActivity").apply {          
                    putExtra(“shipment”, myShipment)        
                }
        }
    }
}

The code above resides in the App module, so it’s available to all dynamic feature modules and holds no explicit reference to Activity classes. It is a little brittle, but made it possible to navigate between modules using little code:

Navigation.ShipmentDetail(shipment).getIntent()

Currently, Google is working on a better way to perform navigation between dynamic features, but the current alpha version isn't stable enough to use in production. Check these instructions for more information.

Dagger in dynamic feature modules

We had to adjust our Dagger setup, so we had an AppComponent which resides in the App module that every Dagger module of our features depends upon. To see how to implement this, read this blogpost written by our colleague Frank.

Seamless flow of opening the PostNL instant app to downloading the full app

Results

It took us almost a year to go from idea to production. To be fair, at some points we feared the instant app would never see the light of day. The most challenging part of this migration was dealing with errors of the Google Play store and finding out why we were receiving them.

In retrospect, we are glad we were able to perform this transition on a large mature app. By taking the initiative, we've learned a lot. Besides, in our everyday work we are able to enjoy the benefits of a more modern architecture setup.

Benefits of this transition for us as a developer:

  • A way better app-architecture.
  • We have since then been able to easily implement on demand dynamic feature modules.
  • We actually understand Dagger.
  • The app is a lot smaller.
  • Developer compile times are shorter.

But the results are best shown in the usage. In just four months, the instant app has resulted in an increase of about 20% in users of the installed version of our app! 🎉

---

Are you a developer that loves to take initiatives like building an instant app? Then do check our Android and other job vacancies (in Dutch) at https://werkenbij.q42.nl!