Lock your Android dependencies 🔐
Tags: android, gradle, dependencies, mobile
Published: 2021-11-11 20:07:43

At <a href="https://www.swile.co/">Swile</a> we had several crashes and regressions caused by hidden dependency changes. We ended up setting a simple dependency lock mechanism to prevent future bugs.

Dependencies locking is a well known mechanism. Web developers have done this for years (package-lock.json or yarn.lock). iOS (Podfile.lock) and Ruby (Gemfile.lock) devs too just to name a few.

In this article we will see why this tool is important and how we can use it in our Android projects.

### TLTR

Here are the article key points :
> Hidden dependency changes can happen **even if you're meticulous**.
> Export the complete dependency graph to a `dependencies.lock` file and add it to your VCS. That way you know what exact versions are used and the relations between libraries.
> Make your CICD fail the build if the file content is different than your actual dependencies.

🎁 You can find the code <a href="#solutions">here!</a> 

<hr/>

### So what is this all about?

Any project relies on lower-level or helper libraries to work. These are your project **direct** dependencies.
Recursively, such direct dependencies also have dependencies. From your project perspective, these are called **transitive** dependencies.

All of this forms a graph, called the dependency graph.

<figure>
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3irkx0ouo35h8hesgnn5.png" alt="Dependency graph" style="width:70%">
<figcaption align = "center">Example of an Android app dependency graph generated with <a href=https://github.com/vanniktech/gradle-dependency-graph-generator-plugin"">Vanniktech Gradle plugin</a>.
firebase-core is a direct dependency whereas firebase-analytics is transitive.
Note that the firebase-common is referenced multiple times.</figcaption>
</figure>

<hr/>

Declaring a direct dependency also means specifying its version. To do so, you can use fixed, ranged, prefixed or even no versioning. Here is an example of some common notations you can find in a `build.gradle` file

```gradle
dependencies {
    implementation "com.google.firebase:firebase-core:20.0.0" // Fixed
    implementation "com.google.firebase:firebase-core:[20.0.0, 20.99.99]" // Ranged
    implementation "com.google.firebase:firebase-core:20.+" // Prefixed
    implementation "com.google.firebase:firebase-core" // Not constrained
...
}
```

Remember that your direct dependencies also have dependencies, and so on. Some may be referenced with fixed versions, some without. Some may be referenced once, other multiple times and in different versions!
See the problems coming? 😉

<hr/> 

For Gradle to choose what version of each dependency to pick in the graph it has to go through a fairly complex process called dependency resolution before building your project. Out of the box Gradle will manage it for you so you won't be asked what to do when there is a conflict.

To see this list simply type `./gradlew app:dependencies`. It will display the whole resolved dependency tree of your `app` module. You can easily see if Gradle fixed a conflict and chose another version.

```
 +--- org.jetbrains.kotlin:kotlin-stdlib:1.4.30 -> 1.5.21
```

Here some library needed kotlin-stdlib in version 1.4.30. Another one needed version 1.5.21. So Gradle decided to pick the biggest version.

<hr/>
🎓 Locking your dependencies means writing down the exhaustive list of all direct and transitive dependencies and their exact resolved versions at a specific point in time.
That way we ensure that our project will be compiled, tested and released against these exact versions. When bumping a library, any hidden change will be explicit, giving you control and understanding.

<hr/>

### But I already use fixed version numbers everywhere, are you sure I need to lock my dependencies? 🤷

A first problem comes from your transitive dependencies: you don't have control over their release process, what libraries and versions they are using.

The dependency graph may then contain the same library multiple times but in different versions.

Here is an imaginary build.gradle file:
```gradle
dependencies {
    implementation "com.squareup.okhttp3:okhttp:4.9.2"
    implementation "com.datadoghq:dd-sdk-android:1.10.0"
...
}
```

If you look closely at [Datadog SDK 1.10.0 dependencies](https://mvnrepository.com/artifact/com.datadoghq/dd-sdk-android/1.10.0), you will see that it requires okhttp:3.12.13

![okhttp maven dependency](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ov7jmvor47aoxpeqzzw1.png)

But when running `./gradlew app:dependencies` you will find that Gradle has resolved the conflict by picking and compiling against the highest declared version of okhtpp. 

```
+--- com.datadoghq:dd-sdk-android:1.10.0
     +--- com.squareup.okhttp3:okhttp:3.12.13 -> 4.9.2
```

<hr/>
🎓 Bumping one direct dependency version in your project, may force one of your transitive dependencies to use this version despite the fact it has not been tested against. 
<hr/> 

Here is another imaginary build.gradle file :

```gradle
dependencies {
    implementation "com.onesignal:OneSignal:4.3.0"
...
}
```

If you look closely at [OneSignal 4.3.0 dependencies](https://mvnrepository.com/artifact/com.onesignal/OneSignal/4.3.0), you will see that it requires work-runtime in a from version 2.0.0 up to 2.99.99.

![wildcard Maven dependency](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wdhsuua7y196dpnub1bq.png)

When running `./gradlew :app:dependencies` you will find that Gradle has resolved the version range by picking and compiling against the highest published version (at the time of your build) of work-runtime.

```
+--- com.onesignal:OneSignal:4.3.0
     +--- androidx.work:work-runtime:[2.0.0, 2.9.99] -> 2.5.0
```

But what will happen if Google releases a new version of work-runtime the next day? Gradle may pick it and change your dependency graph.
And what will happen if this version contains bugs or if OneSignal does not handle it well? Your app won't compile, work correctly or worse, will crash.

Sometimes <a href="https://t.co/yMnnDJLHfZ">a vulnerability is found in a library version</a>. It is quick and easy to spot if your project has a direct dependency on this specific version. This is why tools such a Dependabot exist. However you won't be warned if this version appears in your transitive dependencies.

<hr/>
🎓 Because of version ranges, a new release of a dependency (direct or transitive) may cause Gradle to pick it without you noticing it
<hr/>

## Okay I'm convinced, what can I do? <a name="solutions"></a>

Gradle offers [a dependency locking mechanism](https://docs.gradle.org/current/userguide/dependency_locking.html) out-of-the-box. A [Nebula Gradle plugin](https://github.com/nebula-plugins/gradle-dependency-lock-plugin) also exists.

As an alternative, in the next section of this post I will show you how to implement a basic locking mechanism. It is using the output of the `./gradlew app:dependencies` command. This work was inspired by this [Jake Wharton post](https://developer.squareup.com/blog/surfacing-hidden-change-to-pull-requests/).

Code has some rough edges but can be a great starting point for an integration in your project CICD pipeline!

Story begins with the `generateCurrentDependencies` Gradle task. It stores the output of the `./gradlew app:dependencies` command to a temporary file.

{% gist https://gist.github.com/borsini/a501f5927d8a2fb90b11213c50b8d5dd file=generateCurrentDependencies.gradle %}

This task is used by both `generateDependenciesLockFile` and `compareDependencies` tasks.

{% gist https://gist.github.com/borsini/a501f5927d8a2fb90b11213c50b8d5dd file=lockDependencies.gradle %}

`./gradlew generateDependenciesLockFile` copies the content of the temporary file to your root project directory under a file called `dependencies.lock`

{% gist https://gist.github.com/borsini/a501f5927d8a2fb90b11213c50b8d5dd file=compareDependencies.gradle %}

`./gradlew compareDependencies` fails if the content of the `dependencies.lock` file is different from the actual project resolved dependencies. A call to the `diff` command allows to quickly spot the differences. See Jake Wharton [dependency-tree-plugin](https://github.com/JakeWharton/dependency-tree-diff) if you want to display better diffing results.

## How can I use in my projects ?

First step would be to copy these tasks in a file and load it from your main build.gradle

```gradle
apply from: file('gradle/dependenciesLock.gradle')
```

Next you have to generate the lock file and commit it to your VCS. 
🎉 Congratulations 🎉 this is your baseline, you're now in control !

What happens next will depend on your workflow. If you have a CICD system, you can plug it in. By calling `./gradlew compareDependencies` for each build, you will ensure to be warned each time a change has been made on one of your dependencies.
If you don't have such a system, you still can run those tasks manually after each dependency bump to check the impacts.

A good advice would be to bump one dependency after another, update the lock file then commit those changes. By doing atomic changes, impacts will be explicit and a regression easier to revert. 

This is how we decided to proceed at <a href="https://www.swile.co/">Swile</a> and so far we are fairly happy with it !

### Useful links

[Gradle dependency resolution](https://docs.gradle.org/current/userguide/dependency_resolution.html)
[Gradle dependency version notation](https://docs.gradle.org/current/userguide/single_versions.html")
[Surfacing hidden changes - Jake Wharton](https://developer.squareup.com/blog/surfacing-hidden-change-to-pull-requests/)
[Dependency Tree Plugin - Jake Wharton](https://github.com/JakeWharton/dependency-tree-diff)