← Back to indexNiranjan
Essay · IIDec 20184 min read

Patching JaDX at build time

How I shipped JaDX inside an Android app by rewriting two of its methods at build time, without forking it.

Show Java is an Android app I started in 2013 that decompiles APKs on the phone itself. Pick an installed app or a file off storage, get Java source back, browse it inside a code viewer. The interesting decompiler in 2018 was JaDX. It went straight from dex to Java without the dex2jar detour, the output was the cleanest of the bunch, and the project was actively maintained. The problem was that JaDX assumes it's running on a desktop JVM, and the phones I had to support did not have one.

Two parts of the JVM mattered. The first was java.nio.file.*. Android only added it in API 26, Oreo. Below that, the classes simply don't exist. The second was that JaDX has no per-class progress callback for the UI. From the user's seat, the app would freeze on a non-trivial APK with nothing on screen except a spinner, then dump a hundred files at once. Fine on a workstation. Not fine when the user is sitting on a bus watching a progress bar that hasn't moved.

The natural answer was to fork JaDX, patch the source, build a custom jar, and ship that. I didn't want to. Forking a fast-moving project means you own the merge forever, and the changes I needed were tiny: one method to short-circuit, one method to inject a callback into. The cost of owning a fork for two methods was not worth the simplicity it bought me. I wanted to keep updating to upstream JaDX and have the patches reapply themselves automatically every build.

So I patched at the bytecode level, at build time. There's a Gradle plugin called hiBeaver, by bryansharp, that lets you rewrite methods inside dependency jars as part of the build. You declare the target class, the target method signature, and the new body. Gradle takes the dependency jar, rewrites the two methods, and the output that goes into the dex is the patched version. The original JaDX jar stays untouched in the repo.

Method one: jadx.core.utils.files.FileUtils.isCaseSensitiveFS. JaDX uses this to decide how to handle file naming on the output filesystem. The original uses java.nio.file.Files to check the host. On Android < 26 that path NoClassDefFoundErrors immediately. The patch is two bytecodes: ICONST_1, IRETURN. Always return true. Android's filesystem behaviour is consistent enough that this is fine; the worst case is a case-collision that gets renamed, and you can dedup that downstream.

Method two: jadx.core.dex.visitors.SaveCode.save. This is the method JaDX calls every time it writes a decompiled class to disk. The patch injects a single line at method entry that calls back into com.njlabs.showjava.utils.streams.Logger.logJadxClassWrite. The UI listens, the progress bar moves. JaDX never knows it's being watched.

There was a third, adjacent hack that lived in the same build.gradle. JaDX needs to convert jar to dex internally, and it uses Google's dx tool to do it. Shipping dx-1.14.jar on the device collides with lib-art.jar, which is what the Android runtime ships in the same com.android.* namespace. The fix was Tinkoff's jarjar Gradle plugin, repackaging the com.android.** tree inside dx to xyz.codezero.android.@1 at build time. Same idea. Don't touch the source, rewrite at build.

I'm not a Java expert, and at no point was I a confident reader of JVM bytecode. The patches in build.gradle are something I arrived at by reading hiBeaver's README, the JaDX source for the two methods, and a lot of failed builds. The right thing, in some abstract sense, was to file an issue upstream and ask JaDX to gate the java.nio.file.Files call on a runtime check. I didn't do that. The aim was to give the user JaDX, which is a good decompiler, on a phone that didn't have a JVM to run it on, and the patches did that, and I had other things to ship.

Thanks for reading. Questions, disagreements, or corrections,
.