Dynamic Dependencies: An Android Gradle Trap!

Diego Recalde
Globant
Published in
5 min readDec 5, 2023
Photo by Lucas van Oort on Unsplash

During my growth as an Android developer, I’ve encountered many perplexing issues. At first glance, some seem to have no rhyme or reason, whether they’re race conditions, UI glitches, slowness, or other anomalies. But, one of the most elusive challenges is a dependency conflict. In medium to large projects with many dependencies — including third-party libraries that bring their own — a lack of caution can lead us into a dependency hell.

The Problem

Gradle, described as “a build automation tool for multi-language software development,” is the one we use for defining dependencies in Android projects. One day, the project I was working on began failing during the build-creation phase of our pipeline. This happened a day after we released a similar build that our QA team was already testing. No major changes had been made since that release. To make matters more puzzling, even previous versions of our app — ones already released in production — started failing in the local environment of the development team. The error messages displayed were something like this:

e:/…/transformed/jetified-kotlinx-coroutines-android-1.7.1.jar!/META-INF/kotlinx-coroutines-android.kotlin_module: Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.8.0, expected version is 1.6.0.

e:/…/transformed/jetified-kotlin-stdlib-1.8.21.jar!/META-INF/kotlin-stdlib-jdk7.kotlin_module: Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.8.0, expected version is 1.6.0.

e:/…/transformed/jetified-kotlin-stdlib-1.8.21.jar!/META-INF/kotlin-stdlib-jdk8.kotlin_module: Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.8.0, expected version is 1.6.0.

Digging into the error messages, it was pretty clear that there was some beef between Kotlin versions 1.6 and 1.8. This sort of made sense, seeing as our project was still running on v1.6.21. The errors also highlighted a couple of conflicting dependencies. But here’s where it got tricky: in this project, dependencies are declared in individual build files. And with the bunch of internal and external modules we had, pinpointing the exact module or third-party library causing the fuss turned into a bit of a puzzle.

Luckily, we had some tools up our sleeve to hunt down the problem. For the first time, I dove into the Gradle dependency tree. Using the “dependencies” task, I laid out the whole list of our project’s dependencies. Run the task, and you’ll see something that looks a bit like this:

https://docs.gradle.org/current/userguide/viewing_debugging_dependencies.html#sec:listing_dependencies

I thought this would be a walk in the park, but man, there’s a ton of info on how dependencies are listed and how Gradle manages conflicts between them, among other things. Plus, our tree was way more branched out than what you see above. That said, the error logs gave me a hint on where to start. I began focusing on stuff like the Kotlin stdlib and the Coroutines library version. After a good few hours and some teamwork with a colleague, we finally spotted this:

A small peek at part of my project’s dependencies tree

A third-party library in our project had a dynamic dependency on Firebase analytics. More specifically, it was set as a range of versions, which Gradle permits. But just because you can do something doesn’t mean it’s good practice.

Let’s Analyze our Findings

Okay, here’s the deal: if you look at the dependency tree, you’ll notice a few things. First, this analytics dependency is set with a range of versions, meaning it’ll snag the latest one available. But wait, there’s more. Further down, there’s a “beta” version from the Play Services Measurement SDK — a massive red flag! And if you scroll down a bit more, you’ll spot the stdlib for Kotlin 1.8.21.

Now, you might be thinking: isn’t the point of dynamic dependencies to grab the latest version? That way, you get “backward compatibility,” the newest features, and you skip the hassle of manually updating and pushing a new app or SDK version. Sounds neat, right? riiiiight? Well… No, not really, or at least while that argument might’ve been valid in the past, it shouldn’t be valid anymore, especially in 2023.

To get to the bottom of this, with the two red flags from our dependency tree in mind, we dove into the release notes for these libraries, aiming to identify the real culprit. Starting with the one on top, we discovered that the latest version of the Firebase analytics library (21.4.0) had been updated a day before our build issues began. This confirmed we were on the right track.

https://firebase.google.com/support/release-notes/android#2023-10-18

But our detective work wasn’t done. We turned our attention to the release notes of the privacy sandbox lib. To our surprise, one of the recent commits for the beta05 version read as follows:

https://android.googlesource.com/platform/frameworks/support/+log/6f3ac2bd197d5e61ab2708125b57d6ae4003ad68..73f902dee011bfe400d8a0330bfd8d4bb632065f/privacysandbox/ads

The Coroutines version had been updated to 1.7.1. Remember that log from the start of the article? It came full circle. As part of the updates in the 1.7.0 version of the Kotlin Coroutines library, they bumped up the Kotlin version to 1.8.20. Yet, our issue was with Kotlin 1.8.21, as highlighted by the logs and the dependency tree. Diving deeper into the dependencies file of the privacy sandbox lib, we found the culprit: Kotlin was declared at version 1.8.21. And with that, our investigative journey came to an end.

Conclusions

To tackle this, you’ve got a few routes to consider, each with its time investment. One approach is to upgrade your entire app to the latest Kotlin version — or, at the very least, settle on the highest version among the conflicting ones. This means updating all your modules and ensuring no other part of your app goes haywire.

If you’re pressed for time and need a quicker fix, another option is to simply force Gradle to fetch the previous version of the Firebase analytics library, provided it doesn’t immediately mess with any related features. In our case, we chose this route due to time constraints. Fortunately, the code we needed to add was very straightforward:

configurations.all {
resolutionStrategy {
force("com.google.firebase:firebase-analytics:21.3.0")
}
}

The crucial lesson you should gather from my experience is this: always approach dependency management in your project responsibly. Considering a Bill of Materials (BOM) can be beneficial in certain situations. Or, migrating your project to a version catalog might help maintain tighter control over each dependency. But above all, please heed my advice: steer clear of dynamic dependencies. They have the potential to disrupt your build overnight, catching you off guard.

--

--

Diego Recalde
Globant
Writer for

Android developer, gamer, reader, writer, human being...