Mapbox Android SDK for open source projects

In response to https://github.com/mapbox/mapbox-events-android/issues/563

< all posts

Given that the new Mapbox components aren’t going to be published to a public repository, we (@getodk) wanted to find a way to continue to use Mapbox in ODK Collect without forcing open source contributors to create a Mapbox developer account and configure credentials. Collect is a large app, and it’s likely that a contributor would be working in an area that has nothing to do with the mapping features. Furthermore, Collect supports switching between Google Maps and OSM SDKs at runtime to allow different projects to use the mapping “engine” that best fits their use case so even if they were working on mapping features, they still probably wouldn’t need Mapbox working. Given all that, we didn’t want to add a manual, and likely redundant step to configuring a development environment. I wanted to share the solution we ended up with for anyone else who might be in a similar situation.

As Collect uses multiple map SDKs, we’ve ended up with a MapFragment interface that can be implemented for each of these SDKs and a MapFragmentFactory that creates the correct object based on the user’s current settings. This means that our MapFragmentFactory ends up depending (through a Java import) on our MapboxMapFragment (our Mapbox implementation of MapFragment) and that MapboxMapFragment itself depends on the Mapbox SDK (again through an import and through Gradle’s dependency management). If we want to be able to build the app without setting up a Mapbox developer account and configuring credentials, then both these dependencies are a problem - we can’t build the app if we can’t access the Mapbox private repository. Assuming that downloading the SDK and then copying into our codebase or a mirror Maven repository or leaving the downloads key as plaintext in our codebase was out of the questions (as an open source codebase, it feels like these would fall under “redistribution”), we ended up employing dynamic class loading and Gradle multi-module builds to help us.

Note: We did of course run into various other problems that I won’t address here (like Dagger across a conditionally included module and excluding x86 to reduce APK size) as they felt more specific to our particular codebase and product requirements. The “warts and all” changeset can be found here.

Dynamic class loading

To solve the first dependency problem, we can use dynamic class loading to create a MapboxMapFragment instance in MapFragmentFactory without an explicit import for the class:

Class.forName("org.odk.collect.MapboxMapFragment").newInstance() as MapboxMapFragment

Great! We’ve severed that explicit dependency, but unfortunately lost some compile time safety in the process. To alleviate that a little, we can use dynamic class loading to detect whether MapboxMapFragment is present at runtime and remove Mapbox as an option in settings to prevent any nasty crashes:

fun isMapboxAvailable(): Boolean {
    return try {
        Class.forName(className)
        true
    } catch (e: ClassNotFoundException) {
        false
    }
}

Multi-module builds

Collect already uses a multi-module build - it’s pretty typical for larger Android apps. There is an “app” module that ends up depending on a whole host of “feature” modules and mini frameworks that can all be compiled and have their tests run independently (and in parallel). We can use this structure to solve our second dependency problem. Firstly we move our MapboxMapFragment (and any other code that uses the Mapbox SDK) to a new mapbox module. This means we end up with a dependency declaration like this in our app module’s build.gradle:

implementation project(':mapbox')

And in our settings.gradle we have (as well as other module include statements):

include ':app'
include ':mapbox'

It’s important to note that this means the implementation dependency declaration for the Mapbox SDK (com.mapbox.maps:android:<VERSION>) itself now lives in mapbox’s build.gradle, not app’s. Of course, we’re not done yet: when we run any Gradle command or try and build the app we’re still going to see an error if Gradle doesn’t have credentials configured to access Mapbox’s private Maven repository. We need to only include mapbox at both the root Gradle project level and at the app module level if there are configured credentials. For secrets configuration, Collect uses a secrets.properties file that developers can create themselves and is read by Gradle using a getSecrets() helper. As an example, the configuration for the Mapbox repository looks like this:

maven {
    url 'https://api.mapbox.com/downloads/v2/releases/maven'
    authentication {
        basic(BasicAuthentication)
    }
    credentials {
        username = "mapbox"
        password = getSecrets().getProperty('MAPBOX_DOWNLOADS_TOKEN', '')
    }
}

To get everything working, we can put the include and implementation statements behind control flow:

settings.gradle

include ':app'
if (getSecrets().getProperty('MAPBOX_DOWNLOADS_TOKEN', '') != '') {
    include ':mapbox'
}

app/build.gradle

if (getSecrets().getProperty('MAPBOX_DOWNLOADS_TOKEN', '') != '') {
    implementation project(':mapbox')
}

Now Gradle will only try and fetch dependencies for and build mapbox if MAPBOX_DOWNLOADS_TOKEN is present in the developer’s secrets.properties

After all this we have an app that can be built, tested and debugged without a Mapbox download key for those that don’t have one, but only requires the expected manual steps (no uncommenting or commenting code or swapping configuration files for example) for regular maintainers and contributors working on Mapbox specific features.

This approach admittedly feels very convoluted. It’d be great to hear from the Mapbox team if there are simpler ways of getting the Mapbox SDK working for open source applications (without compromising on development environment setup).