Skip to content

Source incremental compilation

When Gradle compiles a module, it does more than convert Java and Kotlin into classes. It establishes the complete classpath, identifies Kotlin module boundaries, connects generated source, checks unchanged callers, and generates DEX under one consistent set of rules.

To shorten an everyday Run, Jugg processes only source changed in the current Run. With fewer files, these guarantees that Gradle normally provides implicitly also disappear. The challenge of source incremental compilation is therefore not how to invoke javac and kotlinc, but how to restore full-build semantics during local compilation and return to Gradle when that cannot be done.

What is lost when only changed source is compiled

A trusted Gradle build leaves APKs, classes, dependencies, and compilation parameters for later incremental compilation. The simplest incremental approach can reuse these classes as dependencies and send only changed files to the compiler:

text
latest Gradle artifacts
  -> use as the classpath for the current Run
  -> compile changed Java / Kotlin
  -> generate new classes

This path avoids complete task scheduling and many unchanged inputs, but it also reduces what the compiler can see.

Guarantee normally provided by GradleProblem in a simple incremental compilationJugg handling
Complete compilation contextClasspath, module identity, or tool version differsRestore compilation context for the current module from the Gradle baseline
Java, Kotlin, and generated source visible in the same RunThe compiler reads stale symbols from the previous RunOrder compilation by artifact dependency and pass current source
Complete caller checksUnchanged callers do not participate in compilationCompare class structures and continue compiling affected source
Consistent DEX conversionNew DEX has a different desugared structure from the baseline APKRead baseline state, supply affected classpath, and use the same conversion strategy

The following sections explain why these gaps can cause compiler errors or runtime crashes and how Jugg contains them.

Reuse the Gradle baseline instead of reimplementing Gradle

Java compilation is relatively direct. Jugg restores parameters such as the classpath, source inputs, and target bytecode version from the project snapshot and uses the JDK compiler to generate classes. If an Android Studio runtime cannot provide the Java compiler through the standard entry point, Jugg uses another tool entry bundled with the JDK so that a different IDE runtime does not stop incremental compilation.

Kotlin is more sensitive to compilation context. The module name, output directory, and same-module classpath visible to the compiler are not merely command-line details; they change source visibility and final bytecode.

Context mismatchUser-visible result
A same-module class is treated as an external dependencyA smart cast accepted by Gradle fails during incremental compilation
Module name or same-module classpath differsAn internal member becomes inaccessible, or runtime throws NoSuchMethodError
The new module description is not mergedLater Kotlin compilation cannot find a new top-level declaration or extension function

Jugg writes Kotlin output back to the class directory for the current module and passes the same module identity used by Gradle. The compiler then treats old classes and current source as one module. A .kotlin_module generated by single-file compilation is also merged with the original module description so that other Kotlin files can reference new top-level declarations.

Another issue comes from Android Studio itself. The IDE and Kotlin compiler can include classes with the same package names but different versions. Running the compiler directly in the IDE class-loading environment can mix both implementations. Jugg loads the Kotlin compiler and its dependencies in an isolated class-loading environment so that the compiler runtime sees one consistent version.

Kotlin Multiplatform adds common source, platform source, and source set inheritance. Jugg prefers source set and fragment information from Gradle Kotlin tasks. If cache is missing or relationships cannot be confirmed, it does not guess module structure from directory names. Failure of auxiliary information affects only that capability, while ordinary Kotlin compilation keeps its existing path.

Mixed-language projects need current symbols from the same Run

Java and Kotlin frequently reference one another. Suppose one Run changes both a Java interface and its Kotlin implementation. If the Kotlin compiler sees only the old Java class from the baseline, it validates Kotlin against a stale method signature and can produce reference not found, type mismatch, or missing abstract method errors.

Jugg first lets the Kotlin compiler read current Java source. New Kotlin classes then enter the Java compilation classpath so that Java sees the latest Kotlin symbols.

text
generated source from annotation processing, DataBinding, and similar stages
  -> Kotlin reads current Java source
  -> Java reads current Kotlin classes
  -> combine all new classes

Generated source must precede language compilation. If source produced by DataBinding, ViewBinding, annotation processors, or KSP/KAPT appears only after language compilation, the current Run cannot form complete classes. Jugg registers generated source already produced in the current Run as compilation input. If generation context cannot be confirmed, Gradle rebuilds the baseline.

Successful compilation does not prove the change is safe to run

A full compilation checks all source in the module. A local compilation checks only directly changed files and can defer what should have been a compile-time error until runtime.

For example, class A deletes a method while its caller B is unchanged:

text
A deletes a method
  -> A compiles successfully
  -> B is not compiled and retains the old call
  -> after deployment, execution reaches the old call
  -> runtime throws NoSuchMethodError

Adding an abstract method, changing a field signature, or changing an inline method creates similar risks. After the first source compilation, Jugg compares old and new class structures, then uses references from the baseline and deployment history to find callers, subclasses, and corresponding source files and adds them to the next round.

text
changed class
  -> compare with the currently effective class
  -> find method, field, inheritance, or inline impact
  -> query callers, subclasses, and corresponding source
  -> append another compilation round until impact converges

The comparison uses classes that the device can currently load, not only classes in the latest Gradle APK. Changes already deployed incrementally must participate as well; otherwise, consecutive changes would calculate impact from an outdated structure. See recompilation for propagation rules and constant reference analysis for compile-time constants.

New DEX must preserve the structure of the baseline APK

Java and Kotlin produce classes that D8 then converts to DEX. D8 can also perform desugaring, rewriting interface default methods and newer JDK APIs into structures that run on older Android versions.

Incremental compilation cannot decide whether to desugar only from the current module's minSdk. The device runs an APK produced by the latest Gradle build. If the baseline APK was desugared but current DEX is not, a recompiled subclass may no longer contain methods generated during desugaring while the old interface also lacks the original default method, producing AbstractMethodError at runtime. Conversely, if the baseline was not desugared, the current Run must not introduce another structural model unexpectedly.

Jugg infers whether the application baseline was desugared from class structures in the APK and uses the same strategy for current D8. When desugaring is enabled, D8 also needs interfaces related to default methods. Sending the complete module classpath back through D8 would expand the scope, so Jugg first uses the reference index to find related interfaces, subclasses, and callers and builds the minimum temporary classpath for the current Run:

text
new class
  -> query desugaring state from the baseline APK
  -> find interfaces related to default methods and core library rewrite information
  -> assemble the minimum classpath required by the current Run
  -> generate class-granularity DEX with the same desugaring strategy

Jugg also prefers D8 from the Android Gradle Plugin used by the current project, reducing bytecode differences caused by tool versions. It falls back to the Jugg-bundled version only if the project tool cannot load safely or execution fails, while preserving the original failure information for troubleshooting.

How a source incremental compilation ends

The preceding handling converges into a fixed flow:

text
trusted Gradle baseline
  -> collect directly changed source and generated source from the current Run
  -> Kotlin / Java compilation
  -> compare class structures and append affected source
  -> D8 generates DEX and handles desugaring structure differences
  -> release variants process class names with the original mapping
  -> pass local artifacts to deployment

Deleting an entire source file does not create a new compilation input. After a rename, the new path can compile, but deletion of the old path still does not generate a class removal. An old class no longer produced by new source therefore remains in the installed APK or incremental deployment result and may still be reached by direct references, reflection, or class loading. Use a full Gradle build to generate a new APK baseline when you need to verify that the class no longer exists.

This path depends on a trusted baseline. On the first Run, after build scripts or dependency context changes, when generated code cannot be confirmed, when incremental impact grows too large, or when compilation fails without recovery, Jugg runs Gradle and refreshes APKs, classes, and local indexes. Returning to Gradle is a boundary of incremental compilation and never presents incomplete artifacts as a successful result.