One morning I opened Crashlytics and found a single new line in the stack traces: NoClassDefFoundError: java.util.function.Supplier. It did not reproduce on my emulator, and when I filtered by device, nearly every affected user was on Android 6.0.1.
As an indie developer who has kept Android apps running for a long time, I flinch whenever a crash shows up right after a library update. My first thought is always, "Which of my own lines did this?" This time I had not touched my own code at all.
What I want to say up front is that the cause lived in the gap between what newer devices provide and what old ones do, and the fix was one line of build configuration. Below is the order I used to get there, plus the clues I now prepare before asking an agent to help.
Reading the symptom: old OS only, not at launch, not my class
The error text alone narrows things down a lot.
| Clue | Value in this case | What it suggests |
|---|---|---|
| Exception type | NoClassDefFoundError | The class existed at compile time but is missing on the device |
| Missing class | java.util.function.Supplier | A Java 8 API; on Android it only ships with API 24 (7.0) and later |
| Affected devices | Android 6.0.1 (API 23) | Only devices below API 24 crash |
| Top of the stack | A dependency's package | My code did not call it directly; a library uses it internally |
With those four rows, the leading hypothesis is that a library started using a Java 8 API while the app never told the build to substitute it for old OS versions. If the top frame is in your own package, or the error is a ClassNotFoundException, look elsewhere.
Diagnosis order, cheapest first
- Check minSdk. Below 24, you cannot rely on the OS for Java 8 standard APIs.
- Check whether desugaring is enabled. If
coreLibraryDesugaringEnabledis absent, that is the gap. - Find which dependency references Supplier. Confirm that the library you just updated is the one, by looking at bytecode.
- Reproduce on an old OS before and after the fix.
Step 3 fits in a small script. Class files keep their constant-pool strings as plain text, so you can grep a jar without unpacking it.
#!/usr/bin/env bash
# What this solves: list dependency jars that reference java.util.function.Supplier
# Usage: ./find-supplier-users.sh path/to/jars/*.jar
set -euo pipefail
for jar in "$@"; do
# Concatenate the class files and count occurrences of the reference string
hits=$(unzip -p "$jar" '*.class' 2>/dev/null | grep -a -c 'java/util/function/Supplier' || true)
if [ "${hits:-0}" -gt 0 ]; then
echo "${hits} ${jar}"
fi
done | sort -rnIf the jar it prints matches the library you just updated, the hypothesis is nearly confirmed. I read the jars directly because a Gradle dependency tree tells you who depends on whom, not who uses which API.
The fix: turn on core library desugaring
For apps with minSdk below 24, Gradle can bundle Java 8+ standard APIs into the app at build time. In a Groovy build.gradle:
android {
compileOptions {
// Bundle Java 8+ standard APIs that old OS versions lack
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
// Check the official docs for the current version before pinning
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
}In the Kotlin DSL it is isCoreLibraryDesugaringEnabled = true. The setting rewrites references in dependency bytecode as well as your own, which is why one line in the app can cover a gap inside a library you cannot edit.
For me, adding it and rebuilding was enough to stop the Android 6.0.1 crashes. I rolled the release out in stages and watched the same exception fade from Crashlytics over several days.
Four clues I prepare before asking an agent
The handoff is where I lost the most time on this one, so I'll spend a little longer on it.
The first time, I pasted the whole stack trace into the agent and asked it to "find the cause." The agent started guessing from how newer devices behave, and the session drifted into reading a dependency's changelog line by line. It did not go well.
Now I prepare these four things first:
- OS version and API level. I write "Android 6.0.1 (API 23) only."
- minSdk and whether desugaring is on. I paste the relevant
build.gradlelines as they are. - The dependency I just updated, with before and after versions. The smaller the diff, the narrower the search.
- Where it reproduced and where it did not. "Not reproducible on an API 34 emulator" is a real clue.
Handing over an outline of what is missing, rather than only the symptom, shortened the investigation considerably. I may be missing a better way, but this is what worked for me.
Give the agent the outline of what is missing, not just the symptom. I try to keep to that even on days when I am tired.
Confirming the fix
- Walk the same screen flow once on an old OS. An API 23 emulator is enough.
- Check a release build. A debug build can pass while a release build, with R8 involved, behaves differently.
Both are plain chores, and skipping them is how I end up with the same morning again after the next update.
What to do next
Open your app's minSdk first. If it is below 24, check whether coreLibraryDesugaringEnabled is present. I look at those two places every time I update a library, and I'd suggest making it a habit too.