<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged gradle</title>
<link>https://coderlegion.com/tag/gradle</link>
<description>Powered by Question2Answer</description>
<item>
<title>Fix Gradle Dependency Verification Failures</title>
<link>https://coderlegion.com/31515/fix-gradle-dependency-verification-failures</link>
<description>&lt;p&gt;A Gradle dependency verification failure occurs immediately after upgrading a plugin, adding a module, bumping a library, or enabling a benchmark configuration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Quick solution:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Run &lt;code&gt;./gradlew --write-verification-metadata sha256&lt;/code&gt; to update your allowlist incrementally while preserving the security gate.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew --write-verification-metadata sha256
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Gradle's &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.gradle.org/current/userguide/dependency_verification.html&quot;&gt;dependency verification guide&lt;/a&gt; covers checksums, signatures, metadata generation, and verification failures. The fastest-looking fix is also the most dangerous one: delete verification-metadata.xml or disable verification until CI turns green. Do not do that by reflex.&lt;/p&gt;
&lt;p&gt;Gradle's dependency verification exists to make unexpected artifact changes visible. A failure can mean a normal new dependency is missing from the allowlist, but a checksum mismatch can also mean the bytes are not the bytes you previously trusted. The right workflow starts by classifying the failure.&lt;/p&gt;
&lt;h3 id=&quot;first-identify-which-failure-you-actually-have&quot;&gt;First identify which failure you actually have&lt;/h3&gt;
&lt;p&gt;Two errors that look similar have very different risk profiles.&lt;/p&gt;
&lt;h4 id=&quot;missing-verification-metadata&quot;&gt;Missing verification metadata&lt;/h4&gt;
&lt;p&gt;A typical message says the artifact has no checksum in the verification metadata. That often happens after adding a new dependency, updating a version, introducing a new test or benchmark module, resolving a previously unused configuration, or changing a plugin that pulls new transitive artifacts. This is usually an expected maintenance task, but the new hashes still need review.&lt;/p&gt;
&lt;h4 id=&quot;incorrect-checksum&quot;&gt;Incorrect checksum&lt;/h4&gt;
&lt;p&gt;A more serious failure says Gradle expected one checksum but calculated another. That means the artifact identity changed. Possible explanations include a corrupted local cache, different repositories serving non-identical builds, a republished artifact, repository shadowing, or an actual compromised dependency. Gradle's current documentation explicitly treats incorrect checksums as a condition that requires manual investigation. Do not regenerate until green when the failure is a mismatch.&lt;/p&gt;
&lt;h3 id=&quot;read-the-verification-report-before-changing-metadata&quot;&gt;Read the verification report before changing metadata&lt;/h3&gt;
&lt;p&gt;Gradle produces a verification report that identifies the artifact and configuration involved. In CI, you can also enable more verbose console output when retrieving the HTML report is inconvenient.&lt;/p&gt;
&lt;p&gt;The useful debugging questions are which artifact failed, whether the checksum is missing or different, which configuration resolved it, whether that configuration was introduced by the change you expected, which repository supplied the artifact, whether the version is fixed or changing, and whether you can verify the expected bytes from an authoritative source. This prevents the common mistake of assuming the dependency declared in build.gradle is the one that failed. The failing item may be a plugin artifact, POM, metadata file, test runner, Jacoco agent, or transitive dependency.&lt;/p&gt;
&lt;h3 id=&quot;understand-why-one-change-can-produce-dozens-of-failures&quot;&gt;Understand why one change can produce dozens of failures&lt;/h3&gt;
&lt;p&gt;Dependency verification is global to the build. The same verification metadata applies across the root project, subprojects, buildSrc, plugins, and included builds. Adding one module can cause Gradle to resolve configurations that were never part of the previous baseline.&lt;/p&gt;
&lt;p&gt;For Android and Kotlin projects, these less-obvious configurations often include benchmark runtime classpaths, test fixtures, lint tooling, &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/android-code-coverage-ci-jacoco/&quot;&gt;code coverage&lt;/a&gt; agents, Kotlin compiler plugins, Gradle plugins and their metadata, and included builds. That is why adding a single module can become dozens of failed artifacts. It is not necessarily independent problems. It may be one new dependency graph that has never been recorded.&lt;/p&gt;
&lt;h3 id=&quot;generate-new-metadata-incrementally&quot;&gt;Generate new metadata incrementally&lt;/h3&gt;
&lt;p&gt;Gradle supports generating verification metadata directly. For projects using signature verification as well, use the combined flag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew --write-verification-metadata sha256,pgp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generation is incremental. Existing information can be preserved while Gradle adds newly resolved artifacts. That is better than deleting the file and rebuilding trust from scratch. A safer maintenance sequence is to keep the current verification file, reproduce the failing resolution, generate the missing metadata, inspect the Git diff, verify the new entries, rerun the build, and commit code and reviewed verification changes together.&lt;/p&gt;
&lt;h3 id=&quot;use-dry-run-output-to-inspect-broad-changes&quot;&gt;Use dry-run output to inspect broad changes&lt;/h3&gt;
&lt;p&gt;If an upgrade would add many entries, previewing the generated metadata can make review easier. Gradle documents a dry-run mode that writes a separate verification metadata file instead of replacing the main one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew --write-verification-metadata sha256 help --dry-run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Be aware of the limitation: a dry run does not execute tasks, so dependencies resolved only during task execution can be missing from the preview. That matters for Android pipelines where instrumentation, benchmark, code generation, or packaging tasks pull additional artifacts.&lt;/p&gt;
&lt;h3 id=&quot;review-generated-entries-carefully&quot;&gt;Review generated entries carefully&lt;/h3&gt;
&lt;p&gt;Generated metadata is not automatically verified truth. Gradle marks automatically produced trust information so maintainers can distinguish generated values from values they have reviewed.&lt;/p&gt;
&lt;p&gt;The review process should ask whether the group, module, and version are expected, whether it came from the repository you intended, whether the dependency change was present in the lockfile or build diff, whether the artifact is signed, whether the checksum can be compared with an authoritative distribution, and whether the update unexpectedly introduced another repository. For critical build tooling, take the time to verify the source independently before changing an entry's provenance.&lt;/p&gt;
&lt;h3 id=&quot;never-turn-a-mismatch-into-a-missing-checksum-workflow&quot;&gt;Never turn a mismatch into a missing-checksum workflow&lt;/h3&gt;
&lt;p&gt;Suppose CI reports an expected sha256 that differs from the actual sha256. Running write-verification-metadata immediately can normalize the new bytes into your trusted file and erase the warning you needed to investigate.&lt;/p&gt;
&lt;p&gt;Instead, clear only the suspect cached artifact and redownload it, confirm the repository endpoint, compare the artifact with an upstream checksum or signature when available, inspect whether the publisher legitimately republished the same version, and accept an additional checksum only when you understand why the variation exists.&lt;/p&gt;
&lt;h3 id=&quot;keep-verification-changes-reviewable-in-git&quot;&gt;Keep verification changes reviewable in Git&lt;/h3&gt;
&lt;p&gt;verification-metadata.xml belongs in version control. A good pull request makes the relationship obvious between the dependency version change, the verification metadata change for that dependency graph, and the CI verification run. Avoid bundling unrelated checksum churn with an unrelated feature.&lt;/p&gt;
&lt;h3 id=&quot;ci-needs-the-same-resolution-path&quot;&gt;CI needs the same resolution path&lt;/h3&gt;
&lt;p&gt;A local build can pass while CI still fails because the two environments resolve different tasks. Examples include CI running macrobenchmark tasks, downloading sources, enabling release variants, or resolving artifacts missing from a warm local cache. The fix is not to add random hashes until CI becomes quiet. Reproduce the exact CI task set and generate metadata through that resolution path.&lt;/p&gt;
&lt;h3 id=&quot;verification-and-execution-checklist&quot;&gt;Verification and execution checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Preserve the existing metadata file instead of deleting it entirely.&lt;/li&gt;
&lt;li&gt;Classify missing metadata versus mismatched checksum before running generators.&lt;/li&gt;
&lt;li&gt;Inspect the exact artifact and configuration from the failure report.&lt;/li&gt;
&lt;li&gt;Reproduce the failing resolution path locally using the exact build configuration.&lt;/li&gt;
&lt;li&gt;Generate missing data incrementally using the Gradle flags and review every new entry in the Git diff.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Dependency verification is supposed to interrupt you. The interruption is useful because dependencies are executable inputs to your build. When one of those inputs changes unexpectedly, a secure pipeline should make you explain why. The goal is not to make verification stop complaining, but to make the new dependency graph understood, reviewed, and reproducible.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31515/fix-gradle-dependency-verification-failures</guid>
<pubDate>Sat, 17 Oct 2026 14:45:06 +0000</pubDate>
</item>
<item>
<title>Minimum Weight: Code Coverage Gates in Kotlin Multiplatform with Kover</title>
<link>https://coderlegion.com/28289/minimum-weight-code-coverage-gates-in-kotlin-multiplatform-with-kover</link>
<description>&lt;p&gt;After a GT race, every car rolls onto the weighbridge before parc fermé. It has to clear a minimum weight, set before the season and revised only upward as the series collects more data on what's fair. A car that comes in light gets disqualified, no matter how well it drove. Teams don't get to argue the number down when it's inconvenient. They add ballast, or they find grip somewhere else.&lt;/p&gt;
&lt;p&gt;I think about that weighbridge every time I look at a coverage report.&lt;/p&gt;
&lt;p&gt;A percentage on its own tells you almost nothing. Ninety percent coverage on a module full of generated code and empty getters is worse than sixty percent on a module where every line was actually exercised by a test that would fail if you broke something. I found this out the annoying way, wiring Kover into a project with close to twenty modules: the number moved, but it wasn't telling me the truth.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-number-was-lying-before-it-was-useful&quot;&gt;The number was lying before it was useful&lt;/h3&gt;
&lt;p&gt;The first pass was simple. Apply the Kover plugin, point it at every module, generate a report. The merged number came out somewhere in the low sixties. Respectable enough on paper, wrong in a way that mattered.&lt;/p&gt;
&lt;p&gt;Domain modules with almost no real logic were sitting near the top of the report, and it wasn't because they were well tested. It was because a dependency injection library had generated a pile of factory classes and graph interfaces that every counted line inflated the denominator on, without a single one of them ever being able to fail a test. Compose &lt;code&gt;@Preview&lt;/code&gt; functions did the same thing in the other direction: pure decoration, zero tests, dragging modules with real logic down next to modules that had none.&lt;/p&gt;
&lt;p&gt;You can't set a meaningful floor on a number built like that. So before the floor came the exclude list, and before the exclude list came trusting the report at all.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;coverage-across-more-than-one-module&quot;&gt;Coverage across more than one module&lt;/h3&gt;
&lt;p&gt;A single-module Android app can get away with Kover's defaults: apply the plugin, run &lt;code&gt;koverHtmlReport&lt;/code&gt;, done. A multi-module KMP project can't, for two reasons.&lt;/p&gt;
&lt;p&gt;First, most modules don't need coverage measured at all. Pure API modules, thin platform-wrapper modules, anything with no meaningful branches, don't need a plugin dragging a bytecode instrumentation step into every build. Second, the number you actually care about is the &lt;em&gt;merged&lt;/em&gt; one across every module that does carry logic, not twenty separate reports you have to average in your head.&lt;/p&gt;
&lt;p&gt;The way I did was creating a convention plugin that applies Kover to a module and defines the excludes, plus a root-level aggregation that pulls the modules worth measuring into one merged report.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// build-logic/convention/src/main/kotlin/KoverConventionPlugin.kt
class KoverConventionPlugin : Plugin&amp;lt;Project&amp;gt; {
    override fun apply(target: Project) {
        with(target) {
            val libs = the&amp;lt;LibrariesForLibs&amp;gt;()
            pluginManager.apply(libs.plugins.kover.get().pluginId)

            extensions.configure&amp;lt;KoverProjectExtension&amp;gt; {
                reports {
                    filters {
                        excludes {
                            classes(
                                // DI-generated factories and contribution classes.
                                &quot;*\$Factory*&quot;,
                                &quot;*\$ContributionToAppScope*&quot;,
                                // Compose-compiler-generated lambda holders, no logic of their own.
                                &quot;*ComposableSingletons*&quot;,
                                &quot;*Previews&quot;,
                                &quot;*PreviewsKt&quot;,
                                // Generated resource accessors.
                                &quot;*.generated.resources.*&quot;,
                                // Pure DI wiring: every class under a `.di.` package here
                                // is graph construction, nothing to unit test.
                                &quot;*.di.*&quot;,
                            )
                        }
                    }
                }
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Any module with tests worth counting applies this plugin instead of wiring Kover from scratch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// core/network/build.gradle.kts
plugins {
    id(&quot;template.kover&quot;)
    // ...other convention plugins
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The root project then declares which modules feed the merged report, and repeats the same excludes so the merged view and each module's own view agree with each other:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// build.gradle.kts (root)
plugins {
    alias(libs.plugins.kover)
}

dependencies {
    // Only modules with real tests are listed here. A module with nothing
    // to measure doesn't need to show up in the merged number at all.
    kover(project(&quot;:core:common&quot;))
    kover(project(&quot;:core:network&quot;))
    kover(project(&quot;:core:database&quot;))
    kover(project(&quot;:feature:auth:domain&quot;))
    kover(project(&quot;:feature:auth:data&quot;))
    kover(project(&quot;:feature:profile:presentation&quot;))
}

kover {
    reports {
        filters {
            excludes {
                // Keep in sync with KoverConventionPlugin's excludes.
                classes(
                    &quot;*\$Factory*&quot;,
                    &quot;*\$ContributionToAppScope*&quot;,
                    &quot;*ComposableSingletons*&quot;,
                    &quot;*Previews&quot;,
                    &quot;*PreviewsKt&quot;,
                    &quot;*.generated.resources.*&quot;,
                    &quot;*.di.*&quot;,
                )
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &quot;keep in sync&quot; comment is doing real work. Kover reads excludes from wherever the report is generated. Exclude a class in the convention plugin but forget the same line in the root aggregation, and the merged report counts it again, quietly, and your floor starts failing for a reason that has nothing to do with a real regression.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;excluding-the-honest-way&quot;&gt;Excluding the honest way&lt;/h3&gt;
&lt;p&gt;The instinct when a module reads 0% is to exclude your way to a better number. That's the wrong direction to pull. The right question for anything you're about to exclude is: could a test on this class ever fail for a real reason? If yes, it stays in the count, even if writing that test is annoying. If no, excluding it isn't gaming the number, it's correcting a measurement error.&lt;/p&gt;
&lt;p&gt;Generated database entities and query classes are the clearest case. A SQLDelight-generated &lt;code&gt;UserEntityQueries&lt;/code&gt; class has no branches a test could meaningfully assert on beyond &quot;does the generated SQL run,&quot; which your actual repository tests already cover indirectly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;excludes {
    classes(
        &quot;com.example.app.core.database.UserEntity&quot;,
        &quot;com.example.app.core.database.UserEntityQueries&quot;,
        &quot;com.example.app.core.database.UserEntityQueries\$*&quot;,
    )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The exclude list will grow every time you add a code generator.&lt;/strong&gt; A new annotation processor, a new resource pipeline, a new DI library migration, each one leaves generated classes in its wake that need the same treatment. Treat additions to this list the same way you'd treat additions to &lt;code&gt;.gitignore&lt;/code&gt;: expected maintenance, not a sign something's wrong.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-floor-that-only-goes-up&quot;&gt;The floor that only goes up&lt;/h3&gt;
&lt;p&gt;Once the report was honest, setting a floor was the easy part.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;kover {
    reports {
        verify {
            rule {
                // Baseline was ~64% after excluding generated code.
                // Raised to 68% once core/network got real coverage.
                // Raised to 75% after the auth data layer got tests
                // for its *RepositoryImpl classes.
                // Never lower this to make a failing build pass.
                minBound(75)
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;koverVerify&lt;/code&gt; fails the build if merged line coverage drops below that number. The number itself is a ratchet, not a target. Every time real tests push the baseline up, the floor moves up behind it, a couple of points below the current baseline so unrelated refactors don't fail the build on noise. It never moves down. If a change makes coverage drop, the fix is to write the missing tests, not to edit the floor.&lt;/p&gt;
&lt;p&gt;That's the whole point of the weighbridge analogy holding up under pressure. A minimum weight limit that teams could talk their way under whenever it was inconvenient wouldn't be a rule, it would be a suggestion. Same with a coverage floor you lower the first time it's in your way.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wiring-it-into-ci&quot;&gt;Wiring it into CI&lt;/h3&gt;
&lt;p&gt;Locally, &lt;code&gt;koverVerify&lt;/code&gt; runs alongside &lt;code&gt;koverHtmlReport&lt;/code&gt; and &lt;code&gt;koverXmlReport&lt;/code&gt; as part of the same pre-merge check that runs Detekt (covered in &lt;a rel=&quot;nofollow&quot; href=&quot;/posts/detekt-custom-rules-kmp&quot;&gt;The White Lines&lt;/a&gt;). Coverage belongs in the same slot: a gate that runs before a PR is allowed to merge, not a report someone checks manually once a month.&lt;/p&gt;
&lt;p&gt;If your KMP project already has a GitHub Actions workflow running unit tests on every pull request, adding a coverage gate is one more step, not a new pipeline:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/tests.yml
name: Unit Tests
on:
  pull_request:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: 'gradle'

      - name: Run unit tests
        run: ./gradlew testDebugUnitTest --max-workers=2

      - name: Enforce coverage floor
        run: ./gradlew koverXmlReport koverVerify

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: kover-report
          path: build/reports/kover/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;koverVerify&lt;/code&gt; fails the job the same way a failing test does, so there's no extra branching logic to write. The XML report is worth generating even if nothing downstream reads it yet: it's the format most coverage-badge and PR-annotation tools expect, and generating it costs nothing extra once &lt;code&gt;koverXmlReport&lt;/code&gt; is already in the command.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One gotcha if you're running this on Android modules with Robolectric tests:&lt;/strong&gt; those tests are memory-hungry, and Kover's instrumentation adds overhead on top. If the job runs out of memory before it gets anywhere near reporting a real coverage number, raise the runner's heap before you start suspecting the plugin:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;env:
  GRADLE_OPTS: -Dorg.gradle.jvmargs=&quot;-Xmx4g -XX:MaxMetaspaceSize=1g&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-line-i-hold&quot;&gt;The line I hold&lt;/h3&gt;
&lt;p&gt;A coverage percentage nobody enforces is decoration. It sits at the top of a README, it looks fine in a badge, and it means nothing because there's no consequence when it drops. The moment &lt;code&gt;koverVerify&lt;/code&gt; runs on every pull request and fails the build, the number stops being decoration and starts being a rule the team actually has to answer to.&lt;/p&gt;
&lt;p&gt;I'd rather have a floor at seventy-five that only goes up than a badge that says ninety and hasn't been checked since the module was scaffolded.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Everyone crosses the weighbridge. Nobody gets to argue the number down. 🏁&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28289/minimum-weight-code-coverage-gates-in-kotlin-multiplatform-with-kover</guid>
<pubDate>Wed, 23 Sep 2026 09:00:00 +0000</pubDate>
</item>
<item>
<title>Same Car, Every Time: Deterministic Feature Module Generation in KMP</title>
<link>https://coderlegion.com/27291/same-car-every-time-deterministic-feature-module-generation-in-kmp</link>
<description>&lt;p&gt;Walk down the pit lane at a Porsche Cup weekend and every garage holds the same car. Same chassis from the same jig, same engine sealed by the same people, same tolerances checked against the same sheet. Nobody on that grid gets to be creative about the monocoque. It isn't a lack of ambition, it's the entire point: when thirty cars are identical, a tenth of a second means something. You learn from the difference because the baseline never moves.&lt;/p&gt;
&lt;p&gt;A Formula 1 grid gives you brilliant engineering and twenty-two cars where no two problems have the same cause. Both are real forms of racing. They answer different questions.&lt;/p&gt;
&lt;p&gt;Feature modules are a spec series. I want every one of mine built to the same jig, so that when a module behaves oddly I know it's the code I wrote and not the scaffold it arrived in. It took me a while, and one AI-generated codebase, to work out that I'd been running Formula 1.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;A while back I wrote about custom Gemini commands in Android Studio, and the one I used most was the one that created KMP feature modules. The argument was simple: write down what your project looks like once, stop explaining it from scratch every time. I still believe that. The command file worked.&lt;/p&gt;
&lt;p&gt;Then I went back and read three features I'd generated over about two months.&lt;/p&gt;
&lt;p&gt;One had &lt;code&gt;implementation(project(&quot;:feature:x:domain&quot;))&lt;/code&gt; inside &lt;code&gt;commonMain.dependencies&lt;/code&gt;. One had it at the top level of the &lt;code&gt;kotlin&lt;/code&gt; block. One had it in both places, which somehow still compiled. The &lt;code&gt;ViewModel&lt;/code&gt; in the oldest one extended a base class signature I'd changed since. The package for a hyphenated feature name had come out as &lt;code&gt;user-profile&lt;/code&gt; in one file and &lt;code&gt;user_profile&lt;/code&gt; in the rest, so that module simply didn't build until I fixed it by hand.&lt;/p&gt;
&lt;p&gt;None of these were dramatic failures. Every single one was a five minute fix. But five minutes times four files times every feature is exactly the interruption I built the command to avoid, and now I was paying it at review time instead of at creation time, which is worse, because at review time I've already stopped thinking about this feature.&lt;/p&gt;
&lt;p&gt;So I stopped asking and wrote a bash script.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-thing-i-actually-wanted-was-not-intelligence&quot;&gt;The thing I actually wanted was not intelligence&lt;/h3&gt;
&lt;p&gt;Here's the part that took me embarrassingly long to accept: for this specific job, the AI's greatest strength is the problem.&lt;/p&gt;
&lt;p&gt;An AI generator reads your instructions, reads your codebase, and produces something reasonable. &quot;Reasonable&quot; is a range. It has to be, because the model is inferring intent every time. Ask it twice and you get two points inside that range, both defensible, neither identical. That's exactly what you want when the task is genuinely novel and you can't specify it up front.&lt;/p&gt;
&lt;p&gt;Scaffolding is not that task. Scaffolding is the opposite of that task. I know precisely what the output should be, down to the import order, because I wrote the module it's copying. There is nothing to infer. Every degree of freedom the model has is a degree of freedom I didn't ask for.&lt;/p&gt;
&lt;p&gt;What I wanted was a fixed baseline. Not better output, identical output, so that any difference between two modules is information rather than noise.&lt;/p&gt;
&lt;p&gt;Which raises the fair question of why bash, when a Gradle plugin or an IntelliJ file template would also be deterministic. Both were on the table. The template lost because it stops at files: it can't append to &lt;code&gt;settings.gradle.kts&lt;/code&gt; or splice a route into an existing sealed interface, which is most of what makes a new module actually usable. The Gradle plugin lost on cost. It's the better answer if you're handing this to a team of fifteen, but it's a &lt;code&gt;build-logic&lt;/code&gt; module to maintain and a Gradle build to re-run to test a change, whereas the script is one file I can read top to bottom and edit in place. If the thing ever outgrows bash I'll port it. It hasn't.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-the-script-generates&quot;&gt;What the script generates&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;scripts/new-feature.sh&lt;/code&gt; takes a kebab-case feature name and produces up to three Gradle modules under &lt;code&gt;feature/&amp;lt;name&amp;gt;/&lt;/code&gt;: &lt;code&gt;domain&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;, &lt;code&gt;presentation&lt;/code&gt;. It mirrors the smallest real feature in the project, so the output isn't an idea of what my modules look like, it's a copy of one that already ships.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./scripts/new-feature.sh wishlist
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That gives you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feature/wishlist/domain&lt;/code&gt; with a &lt;code&gt;WishlistRepository&lt;/code&gt; interface&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feature/wishlist/data&lt;/code&gt; with &lt;code&gt;WishlistRepositoryImpl&lt;/code&gt;, bound through Metro&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feature/wishlist/presentation&lt;/code&gt; with &lt;code&gt;WishlistState&lt;/code&gt;, &lt;code&gt;WishlistEvent&lt;/code&gt;, &lt;code&gt;WishlistAction&lt;/code&gt;, &lt;code&gt;WishlistNavigator&lt;/code&gt;, &lt;code&gt;WishlistViewModel&lt;/code&gt;, &lt;code&gt;WishlistScreen&lt;/code&gt;, plus a &lt;code&gt;WishlistGraph&lt;/code&gt; and &lt;code&gt;WishlistGraphNavigator&lt;/code&gt; under &lt;code&gt;navigation/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;three &lt;code&gt;include(...)&lt;/code&gt; lines appended to &lt;code&gt;settings.gradle.kts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;NavigationRouter.Wishlist.Main&lt;/code&gt; route spliced into &lt;code&gt;NavigationRouter.kt&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It compiles and runs immediately. The screen is a &lt;code&gt;PlaceholderScreen&lt;/code&gt; inside a &lt;code&gt;ScaffoldScreen&lt;/code&gt;, the repository interface is empty, and the state holds a single &lt;code&gt;Unit&lt;/code&gt; field so the data class is valid. Nothing pretends to be finished.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;A note on the code you're about to read:&lt;/strong&gt; this is my script, shaped around my project. The paths, the package &lt;code&gt;com.example.template&lt;/code&gt;, Metro for DI, Navigation 3, &lt;code&gt;StateViewModel&lt;/code&gt; and &lt;code&gt;BaseState&lt;/code&gt; from my &lt;code&gt;core/ui&lt;/code&gt; module, the &lt;code&gt;template.*&lt;/code&gt; convention plugins. Dropped into your codebase as-is it won't generate anything that compiles, and that's expected rather than a problem. The value of a generator like this comes from it knowing your conventions, so the interesting part is the structure, not the contents. If you're on Koin instead of Metro, or MVI instead of this MVVM split, or one module per feature instead of three, the shape of the script stays and every heredoc inside it becomes yours. Treat what follows as a worked example to adapt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The name handling is the first thing that stops being a per-feature annoyance:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# kebab-case -&amp;gt; snake_case (Kotlin package segment) and PascalCase (class name prefix)
SNAKE=&quot;${KEBAB//-/_}&quot;
PASCAL=&quot;&quot;
IFS='-' read -ra WORDS &amp;lt;&amp;lt;&amp;lt; &quot;$KEBAB&quot;
for word in &quot;${WORDS[@]}&quot;; do
  first_upper=&quot;$(printf '%s' &quot;${word:0:1}&quot; | tr '[:lower:]' '[:upper:]')&quot;
  PASCAL=&quot;${PASCAL}${first_upper}${word:1}&quot;
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;user-profile&lt;/code&gt; becomes the folder &lt;code&gt;user-profile&lt;/code&gt;, the package segment &lt;code&gt;user_profile&lt;/code&gt;, and the class prefix &lt;code&gt;UserProfile&lt;/code&gt;. Three forms of the same name, derived once, used everywhere. This is the exact transformation an AI gets right most of the time. &quot;Most of the time&quot; is what shipped me a module that didn't build.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;guardrails-come-before-generation&quot;&gt;Guardrails come before generation&lt;/h3&gt;
&lt;p&gt;The script writes into &lt;code&gt;feature/&lt;/code&gt;, appends to &lt;code&gt;settings.gradle.kts&lt;/code&gt;, and rewrites &lt;code&gt;NavigationRouter.kt&lt;/code&gt; in place. Something that edits tracked files without asking should be paranoid about it, so nothing gets written until three checks pass. Two are obvious: the name has to match &lt;code&gt;^[a-z][a-z0-9]*(-[a-z0-9]+)*$&lt;/code&gt;, and &lt;code&gt;feature/$KEBAB&lt;/code&gt; must not already exist. The third is the one I care about:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;if [[ -n &quot;$(git status --porcelain)&quot; ]]; then
  echo &quot;Warning: you have uncommitted changes. This script edits settings.gradle.kts and&quot;
  echo &quot;NavigationRouter.kt in place, and creates new files under feature/$KEBAB.&quot;
  read -r -p &quot;Continue anyway? [y/N] &quot; confirm
  [[ &quot;$confirm&quot; =~ ^[Yy]$ ]] || exit 1
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With a clean tree, &lt;code&gt;git diff&lt;/code&gt; after the run is a complete, readable record of everything the script touched. With a dirty tree, the generated changes are tangled up with yours and you lose the cheapest verification you have. The script can't force a clean tree, but it can make you type &lt;code&gt;y&lt;/code&gt; and think about it for a second.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt; sits at the top, so anything unexpected stops the run rather than leaving half a feature on disk.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;then-i-needed-one-module-not-three&quot;&gt;Then I needed one module, not three&lt;/h3&gt;
&lt;p&gt;I wanted a &lt;code&gt;domain&lt;/code&gt; and &lt;code&gt;data&lt;/code&gt; pair with no UI. A feature that exposes a repository, consumed by an existing screen somewhere else. The script had exactly one mode: all three modules, every time.&lt;/p&gt;
&lt;p&gt;The obvious move, if you're used to AI generation, is to go back and describe the exception. &quot;Same as before, but skip presentation.&quot; And that works. It works right now, on the first try, which is precisely why it's a trap. Because next month I want &lt;code&gt;domain&lt;/code&gt; alone, and the month after I want &lt;code&gt;data&lt;/code&gt; and &lt;code&gt;presentation&lt;/code&gt; with the domain contract living in a shared module, and each of those is a fresh description of an exception to a structure that nobody has actually written down. Every exception is a new chance for the output to drift from the last one.&lt;/p&gt;
&lt;p&gt;Rigidity was the whole reason I wrote the script. The answer isn't to soften it, it's to make the flexibility part of the spec.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Usage: ./scripts/new-feature.sh &amp;lt;feature-name&amp;gt; [--modules &amp;lt;module&amp;gt; [&amp;lt;module&amp;gt; ...]]
./scripts/new-feature.sh wishlist                    # domain + data + presentation
./scripts/new-feature.sh wishlist --modules domain data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three booleans default to true, and &lt;code&gt;--modules&lt;/code&gt; flips them all off and turns back on only what you named:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;GEN_DOMAIN=false
GEN_DATA=false
GEN_PRES=false

for module in &quot;${MODULES[@]}&quot;; do
  case &quot;$module&quot; in
    domain) GEN_DOMAIN=true ;;
    data) GEN_DATA=true ;;
    presentation) GEN_PRES=true ;;
    *)
      echo &quot;Error: unknown module '$module'. Must be one of: domain, data, presentation.&quot; &amp;gt;&amp;amp;2
      exit 1
      ;;
  esac
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The flag accepts at most two names, deliberately. &lt;code&gt;--modules domain data presentation&lt;/code&gt; is already the default, and offering two ways to say the same thing is how flags start rotting. If you want all three, don't pass the flag.&lt;/p&gt;
&lt;p&gt;That cap has a hole you only find by running your own tool wrong: &lt;code&gt;--modules domain domain&lt;/code&gt; has a length of two, passes the count check, and sets one boolean. Comparing how many booleans ended up true against how many names were typed catches it in three lines. Small thing, and the sort of edge case no amount of prompting reliably covers, because you'd have to think of it in advance to ask for it — and once you've thought of it you may as well encode it.&lt;/p&gt;
&lt;p&gt;Notice what this is: a flag with a validated, closed set of values. The flexibility is real and it's bounded. Every combination that's allowed is a combination I decided was allowed, and the ones I didn't decide on fail loudly at argument parsing rather than quietly at compile time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conditionals-are-where-the-real-work-is&quot;&gt;Conditionals are where the real work is&lt;/h3&gt;
&lt;p&gt;Adding the flag was twenty minutes. Making the &lt;em&gt;generated code&lt;/em&gt; correct for each combination was the rest of the afternoon, and this is the part I think matters most.&lt;/p&gt;
&lt;p&gt;If you generate &lt;code&gt;data&lt;/code&gt; without &lt;code&gt;domain&lt;/code&gt;, the data module's &lt;code&gt;build.gradle.kts&lt;/code&gt; must not depend on a domain module that doesn't exist. Neither branch is clever, and that's the point:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;if $GEN_DOMAIN; then
  cat &amp;gt; &quot;feature/$KEBAB/data/build.gradle.kts&quot; &amp;lt;&amp;lt;EOF
import com.android.build.api.dsl.LibraryExtension

plugins {
    alias(libs.plugins.template.data)
}

extensions.configure&amp;lt;LibraryExtension&amp;gt;(&quot;android&quot;) {
    namespace = &quot;com.example.template.feature.$SNAKE.data&quot;
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(project(&quot;:feature:$KEBAB:domain&quot;))
        }
    }
}
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and the &lt;code&gt;else&lt;/code&gt; branch is the same file with the whole &lt;code&gt;kotlin { }&lt;/code&gt; block gone.&lt;/p&gt;
&lt;p&gt;Those &lt;code&gt;alias(libs.plugins.template.data)&lt;/code&gt; lines are convention plugins from my &lt;code&gt;build-logic&lt;/code&gt; module. Each layer gets one plugin ID, and everything else, targets, compiler options, common dependencies, lives centrally. It's the same setup I mentioned in the Gemini article. Worth noting here because it's part of why the generated Gradle files are short enough to template safely: there's very little per-module configuration left to get wrong.&lt;/p&gt;
&lt;p&gt;The second consequence is more interesting. Without a domain module, there is no &lt;code&gt;WishlistRepository&lt;/code&gt;, so &lt;code&gt;WishlistRepositoryImpl&lt;/code&gt; has nothing to implement and nothing to bind. The script doesn't generate a degraded version of it. It doesn't generate it at all, and it tells you why:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo &quot;Note: domain not generated — feature/$KEBAB/data has no ${PASCAL}RepositoryImpl&quot; \
  &quot;(it would need ${PASCAL}Repository from the domain module).&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That line took a couple of iterations to get right and I'd argue it's the most valuable output the script produces. A generator that silently omits things trains you to distrust it. A generator that says &quot;I left this out, here's the reason&quot; is a tool you can stop checking. The absence is intentional and documented at the moment it happens, in the terminal, while you're still looking.&lt;/p&gt;
&lt;p&gt;Same story for the route registration. &lt;code&gt;NavigationRouter.Wishlist.Main&lt;/code&gt; only exists to be referenced by &lt;code&gt;WishlistGraph&lt;/code&gt;, which lives in the presentation module. No presentation, no route:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;if $GEN_PRES; then
  ROUTER_FILE=&quot;core/ui/src/commonMain/kotlin/com/example/template/core/ui/navigation/NavigationRouter.kt&quot;
  # ... splice the route in
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three modules, generated or not, gives eight combinations if you count naively, six that the argument parser actually allows. Every one produces code that compiles. That's the property I was buying, and it's a property you can verify by running the thing six times, which is not something you can meaningfully do with a prompt.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-one-clever-bit-and-its-escape-hatch&quot;&gt;The one clever bit, and its escape hatch&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;NavigationRouter.kt&lt;/code&gt; is a sealed interface hierarchy, and new routes go in as nested &lt;code&gt;data object&lt;/code&gt; declarations before the final closing brace. There's no marker comment, no anchor, just a &lt;code&gt;}&lt;/code&gt; on its own line at the end of the file.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;LAST_BRACE_LINE=$(grep -n '^}$' &quot;$ROUTER_FILE&quot; | tail -1 | cut -d: -f1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Find every line that is exactly a closing brace, take the last one, extract the line number. Then rebuild the file in three pieces:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;{
  head -n &quot;$((LAST_BRACE_LINE - 1))&quot; &quot;$ROUTER_FILE&quot;
  cat &amp;lt;&amp;lt;EOF

    @Serializable
    data object $PASCAL : NavigationRouter {
        @Serializable
        data object Main : NavigationRouter
    }
EOF
  tail -n &quot;+$LAST_BRACE_LINE&quot; &quot;$ROUTER_FILE&quot;
} &amp;gt; &quot;$ROUTER_FILE.tmp&quot;
mv &quot;$ROUTER_FILE.tmp&quot; &quot;$ROUTER_FILE&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Everything before the brace, the new block, then the brace and anything after it. Written to a temp file and moved into place, so a failure halfway through leaves the original intact rather than truncated.&lt;/p&gt;
&lt;p&gt;I'm aware this is line-based surgery on Kotlin source, and that it depends on a formatting convention holding. Which is why the fallback exists:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;if [[ -z &quot;$LAST_BRACE_LINE&quot; ]]; then
  echo &quot;Warning: couldn't find NavigationRouter.kt's closing brace — add the route manually:&quot;
  echo &quot;    @Serializable&quot;
  echo &quot;    data object $PASCAL : NavigationRouter {&quot;
  echo &quot;        @Serializable&quot;
  echo &quot;        data object Main : NavigationRouter&quot;
  echo &quot;    }&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the assumption breaks, the script says so and prints exactly what you need to paste. It does not guess at an alternative insertion point. A generator that fails visibly and hands you the fix costs you thirty seconds. A generator that improvises when its assumptions break costs you a debugging session, and you won't know to start one, because it looked like it worked.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-it-deliberately-refuses-to-do&quot;&gt;What it deliberately refuses to do&lt;/h3&gt;
&lt;p&gt;Two things the script will not touch, and both refusals are in the header comment so I can't quietly forget them.&lt;/p&gt;
&lt;p&gt;It won't wire &lt;code&gt;WishlistGraph&lt;/code&gt; into anything. A new feature can live in a bottom nav tab, behind a settings row, in a shared overlay, or somewhere I haven't invented yet, and that is an architecture decision with consequences. A generator making it for me is a generator making an architecture decision I didn't review.&lt;/p&gt;
&lt;p&gt;It won't write domain logic. &lt;code&gt;WishlistRepository&lt;/code&gt; is an empty interface with a comment telling you to define the contract. That's the actual work. The script's job was to clear a path to it.&lt;/p&gt;
&lt;p&gt;So the run ends with a list rather than a claim of completion:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Still manual:
  - Wire WishlistGraph(onNavigation = ...) into wherever this feature actually lives
  - Fill in WishlistRepository / WishlistRepositoryImpl with real logic.
  - Run './gradlew :feature:wishlist:domain:detekt ...' (or just commit — the pre-commit hook covers it).
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Building the detekt task list from the same booleans is a two-line addition, and it means the command you're told to run is always the right one for what was actually generated.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-i-still-reach-for-the-ai&quot;&gt;When I still reach for the AI&lt;/h3&gt;
&lt;p&gt;I want to be clear that I didn't replace one tool with a better one. I split one job into two jobs.&lt;/p&gt;
&lt;p&gt;The script handles the part I've done forty times and will do forty more, where I know the answer and want it applied identically every time. The AI handles everything after that: the domain logic, the state modelling for a screen that doesn't resemble any other screen, the migration that touches nine files in ways I can't specify up front. That's genuinely novel work, and the ability to infer intent from a loose description is exactly what makes it useful there.&lt;/p&gt;
&lt;p&gt;There's also a nice side effect. The AI is now working inside a codebase where every feature module has the same shape, because the shape came from a script rather than from six separate acts of interpretation. Consistent context produces better output. The rigid tool makes the flexible one work better.&lt;/p&gt;
&lt;p&gt;A rough test I use now: if I'm about to write a prompt for the third time and it's substantially the same prompt, that's not a prompt, that's a script I haven't written yet. And if the script needs to handle a case it doesn't, the fix is a flag with a validated set of values, not a paragraph of natural language explaining the exception.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;The Gemini article's conclusion was that you should write down what your project looks like once. This one is the next step: for the things you do constantly, write down &lt;em&gt;how&lt;/em&gt; as well, in something that executes rather than interprets. Not my how. Yours, with your package name and your base classes and your DI framework, which is an afternoon of work and the reason the thing is worth anything at all.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--modules&lt;/code&gt; is the whole argument in miniature. I needed the generator to do something it couldn't do. I could have described the exception to a model in about fifteen seconds, and I'd have described it again, slightly differently, every time since. Instead I spent an afternoon making the exception a first-class, validated, six-combinations-all-of-which-compile part of the tool. Now nobody describes anything. You pass &lt;code&gt;--modules domain data&lt;/code&gt; and you get the same two modules I got, and the same two I'll get in November.&lt;/p&gt;
&lt;p&gt;Boring is the feature. Six months from now the newest module and the oldest one will still be the same shape, and that is worth more to me than a generator clever enough to surprise me.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Same jig, same tolerances, same car off the truck every race weekend. The lap time is where the interesting differences belong. 🏁&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27291/same-car-every-time-deterministic-feature-module-generation-in-kmp</guid>
<pubDate>Tue, 15 Sep 2026 14:00:00 +0000</pubDate>
</item>
<item>
<title>How to Fix &quot;Duplicate class ... found in modules&quot; in React Native &amp; Expo</title>
<link>https://coderlegion.com/23340/how-to-fix-duplicate-class-found-in-modules-in-react-native-expo</link>
<description>&lt;p&gt;If you've been developing with React Native or Expo for a while, chances are you've had a project that suddenly refuses to build for what seems like no reason.&lt;/p&gt;
&lt;p&gt;Maybe you installed a new package. Maybe you upgraded Expo. Maybe you only changed a single dependency. You confidently run your Android build, expecting it to compile like always...&lt;/p&gt;
&lt;p&gt;Instead, Gradle greets you with something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Execution failed for task ':app:checkDebugDuplicateClasses'.

Duplicate class androidx.lifecycle.ViewModelLazy found in modules lifecycle-viewmodel-2.8.2.aar and lifecycle-viewmodel-ktx-2.6.1.aar

Duplicate class com.google.android.gms.internal.measurement.zzab found in modules play-services-measurement-base-22.0.0.aar and play-services-measurement-impl-21.6.2.aar

Duplicate class ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At first glance, the error looks straightforward: &lt;em&gt;there are duplicate classes&lt;/em&gt;. Easy enough, right?&lt;/p&gt;
&lt;p&gt;Not exactly.&lt;/p&gt;
&lt;p&gt;In reality, this is one of those Android build errors that can send you down a rabbit hole of Gradle files, dependency trees and Stack Overflow threads, only to realize the real cause was something completely different.&lt;/p&gt;
&lt;p&gt;Let's break down what's actually happening and, more importantly, how to fix it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-this-error-happens&quot;&gt;Why this error happens&lt;/h2&gt;
&lt;p&gt;Every Android library included in your project contains compiled Java or Kotlin classes.&lt;/p&gt;
&lt;p&gt;When Gradle builds your application, it combines all of those libraries into a single APK or AAB.&lt;/p&gt;
&lt;p&gt;If two different dependencies contain the exact same class, Gradle doesn't know which version should be packaged.&lt;/p&gt;
&lt;p&gt;Instead of guessing, it stops the build with a &lt;strong&gt;Duplicate class&lt;/strong&gt; error.&lt;/p&gt;
&lt;p&gt;The difficult part is that the dependency causing the conflict usually isn't the one shown in the error.&lt;/p&gt;
&lt;p&gt;Very often, it's another package pulling in an older or incompatible version behind the scenes.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-most-common-causes&quot;&gt;The most common causes&lt;/h2&gt;
&lt;p&gt;After seeing this error many times, these are usually the culprits.&lt;/p&gt;
&lt;h3 id=&quot;1-two-libraries-depend-on-different-versions-of-the-same-package&quot;&gt;1. Two libraries depend on different versions of the same package&lt;/h3&gt;
&lt;p&gt;This is by far the most common scenario.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Library A requires:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;androidx.lifecycle:lifecycle-viewmodel:2.8.2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;while Library B depends on:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;androidx.lifecycle:lifecycle-viewmodel:2.6.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both versions get added to your dependency graph, eventually leading to duplicate classes.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-an-outdated-react-native-package&quot;&gt;2. An outdated React Native package&lt;/h3&gt;
&lt;p&gt;Older React Native libraries often haven't been updated for newer AndroidX or Google Play Services versions.&lt;/p&gt;
&lt;p&gt;Even though your project is current, that one dependency may still reference libraries released years ago.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-expo-sdk-upgrades&quot;&gt;3. Expo SDK upgrades&lt;/h3&gt;
&lt;p&gt;After upgrading Expo, many native dependency versions change.&lt;/p&gt;
&lt;p&gt;If one third-party package hasn't caught up yet, Gradle may end up resolving incompatible versions.&lt;/p&gt;
&lt;p&gt;This is especially common immediately after major SDK releases.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-mixing-manual-native-configuration-with-expo-managed-dependencies&quot;&gt;4. Mixing manual native configuration with Expo-managed dependencies&lt;/h3&gt;
&lt;p&gt;Sometimes developers manually add Android dependencies that Expo already manages internally.&lt;/p&gt;
&lt;p&gt;The result is two copies of essentially the same library.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;how-to-fix-it&quot;&gt;How to fix it&lt;/h2&gt;
&lt;p&gt;There isn't one universal solution, but these steps solve the majority of cases.&lt;/p&gt;
&lt;h3 id=&quot;step-1-read-the-duplicated-class-carefully&quot;&gt;Step 1 — Read the duplicated class carefully&lt;/h3&gt;
&lt;p&gt;Don't focus on the entire error.&lt;/p&gt;
&lt;p&gt;Look specifically for the two modules involved.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;lifecycle-viewmodel-2.8.2

lifecycle-viewmodel-ktx-2.6.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those names usually point you toward the conflicting dependency.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-find-who-is-importing-it&quot;&gt;Step 2 — Find who is importing it&lt;/h3&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew app:dependencies
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew app:dependencyInsight --dependency lifecycle-viewmodel
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This shows which package is pulling each version into your project.&lt;/p&gt;
&lt;p&gt;It's much faster than guessing.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-update-outdated-packages&quot;&gt;Step 3 — Update outdated packages&lt;/h3&gt;
&lt;p&gt;If one library is significantly behind, updating it often resolves the conflict immediately.&lt;/p&gt;
&lt;p&gt;Always check whether a newer version exists before trying complicated Gradle workarounds.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-clean-the-project&quot;&gt;Step 4 — Clean the project&lt;/h3&gt;
&lt;p&gt;Sometimes Gradle continues using cached artifacts.&lt;/p&gt;
&lt;p&gt;Try:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android

./gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then rebuild.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-regenerate-native-files-expo-&quot;&gt;Step 5 — Regenerate native files (Expo)&lt;/h3&gt;
&lt;p&gt;If you're using Expo Prebuild:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo prebuild --clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This recreates the native Android project using your current configuration.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-force-dependency-versions-only-if-necessary-&quot;&gt;Step 6 — Force dependency versions (only if necessary)&lt;/h3&gt;
&lt;p&gt;Sometimes you need to force Gradle to use one specific version.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;configurations.all {
    resolutionStrategy {
        force &quot;androidx.lifecycle:lifecycle-viewmodel:2.8.2&quot;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use this carefully.&lt;/p&gt;
&lt;p&gt;If two libraries genuinely require incompatible versions, forcing one version may create runtime problems later.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;how-to-avoid-this-error-in-the-future&quot;&gt;How to avoid this error in the future&lt;/h2&gt;
&lt;p&gt;While duplicate class errors can't always be prevented, these habits reduce the chances considerably.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep Expo SDKs reasonably up to date.&lt;/li&gt;
&lt;li&gt;Avoid abandoned React Native libraries.&lt;/li&gt;
&lt;li&gt;Update dependencies together instead of one at a time.&lt;/li&gt;
&lt;li&gt;Review package compatibility before major upgrades.&lt;/li&gt;
&lt;li&gt;Avoid manually adding Android dependencies unless absolutely necessary.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;when-the-error-isn-t-actually-the-problem&quot;&gt;When the error isn't actually the problem&lt;/h2&gt;
&lt;p&gt;One thing that makes this error frustrating is that the duplicate classes are often just a symptom.&lt;/p&gt;
&lt;p&gt;The real issue might be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an outdated library,&lt;/li&gt;
&lt;li&gt;conflicting Firebase dependencies,&lt;/li&gt;
&lt;li&gt;mixed AndroidX versions,&lt;/li&gt;
&lt;li&gt;incompatible Google Play Services packages,&lt;/li&gt;
&lt;li&gt;or an incomplete Expo migration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's why simply searching the duplicated class name doesn't always lead to the correct fix.&lt;/p&gt;
&lt;p&gt;Understanding &lt;strong&gt;why&lt;/strong&gt; those dependencies ended up together is usually more important than the class itself.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;how-fixmyerror-can-help&quot;&gt;How FixMyError can help&lt;/h2&gt;
&lt;p&gt;Instead of manually digging through Gradle output, dependency trees and dozens of forum posts, you can paste the full error into &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.fixmyerrorapp.com&quot;&gt;FixMyError&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The tool analyzes the build log, identifies the likely source of the conflict, explains what's happening in plain English, and suggests fixes based on the specific dependencies involved.&lt;/p&gt;
&lt;p&gt;Rather than treating every duplicate class error as identical, it tries to narrow the problem down to the actual cause behind your particular build failure.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h2&gt;
&lt;p&gt;Duplicate class errors are among the most frustrating Android build issues because the message rarely points directly to the package that's responsible.&lt;/p&gt;
&lt;p&gt;The good news is that, in most cases, the problem comes down to dependency resolution rather than something fundamentally broken in your project.&lt;/p&gt;
&lt;p&gt;Once you identify which libraries are introducing conflicting versions, the fix usually becomes much more straightforward.&lt;/p&gt;
&lt;p&gt;If you're currently staring at a wall of Gradle output wondering where to begin, start by identifying the conflicting modules, trace where they're coming from, and work outward from there.&lt;/p&gt;
&lt;p&gt;It takes a bit of patience, but it's almost always easier than blindly trying random fixes from different Stack Overflow threads.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/23340/how-to-fix-duplicate-class-found-in-modules-in-react-native-expo</guid>
<pubDate>Thu, 23 Jul 2026 12:36:11 +0000</pubDate>
</item>
<item>
<title>How to Fix Gradle Error: Unsupported class file major version (React Native &amp; Expo)</title>
<link>https://coderlegion.com/19548/how-to-fix-gradle-error-unsupported-class-file-major-version-react-native-expo</link>
<description>&lt;p&gt;If your React Native or Expo Android compilation suddenly crashes with a massive terminal stack trace pointing to an &quot;unsupported class file major version&quot; or claims a class file has the wrong version (e.g., &lt;code&gt;wrong version 65.0, should be 61.0&lt;/code&gt;), your environment is caught in a silent version mismatch.&lt;/p&gt;
&lt;p&gt;This error almost always triggers right after you upgrade your Expo SDK, bump your React Native core version, or update Android Studio. &lt;/p&gt;
&lt;p&gt;Here is exactly why this happens, how to decode the cryptic internal version numbers, and how to get your build compiling cleanly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-did-this-happen-suddenly-&quot;&gt;Why Did This Happen Suddenly?&lt;/h3&gt;
&lt;p&gt;When React Native or Expo upgrades their framework architecture, they also upgrade the required version of the &lt;strong&gt;Android Gradle Plugin (AGP)&lt;/strong&gt; inside your project. &lt;/p&gt;
&lt;p&gt;Each version of AGP requires a strict minimum version of the Java Development Kit (JDK) to run the compilation pipeline. If your system's global terminal runtime environment or your IDE is still pointing to an older Java runtime, the build engine throws an immediate exception during the semantic analysis phase.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-version-decoder-cheat-sheet&quot;&gt;The Version Decoder Cheat Sheet&lt;/h3&gt;
&lt;p&gt;Java’s compiler doesn't map version numbers logically to their public marketing names. When the Gradle stack trace throws a decimal number at you, use this index to figure out what version your code is demanding:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Class file major version 66.0&lt;/strong&gt; = Requires Java 22&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Class file major version 65.0&lt;/strong&gt; = Requires Java 21 (Common in newer 2025/2026 native modules)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Class file major version 61.0&lt;/strong&gt; = Requires Java 17 (The baseline standard for modern React Native)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Class file major version 55.0&lt;/strong&gt; = Requires Java 11 (Legacy)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your terminal says &lt;code&gt;wrong version 65.0, should be 61.0&lt;/code&gt;, your build script encountered a dependency compiled for JDK 21, but your system is executing the build toolchain using JDK 17.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-find-the-exact-path-to-your-target-jdk&quot;&gt;Step 1: Find the Exact Path to Your Target JDK&lt;/h3&gt;
&lt;p&gt;Before making adjustments, you need to locate where the correct JDK version is actually installed on your machine. &lt;/p&gt;
&lt;h4 id=&quot;on-macos&quot;&gt;On macOS&lt;/h4&gt;
&lt;p&gt;If you use Homebrew or standard installers, your JDK runtimes are stored under the virtual machine path. You can list all installed environments by running:&lt;br&gt;
&lt;code&gt;/usr/libexec/java_home -V&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Copy the path to the required version. It will look similar to this:&lt;br&gt;
&lt;code&gt;/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home&lt;/code&gt;&lt;/p&gt;
&lt;h4 id=&quot;on-windows&quot;&gt;On Windows&lt;/h4&gt;
&lt;p&gt;By default, standard Java installers or Android Studio bundle their JDK environments inside program directories. Check these common locations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;C:\Program Files\Java\jdk-17\&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;C:\Program Files\Android\Android Studio\jbr\&lt;/code&gt; (Android Studio's embedded Java runtime)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-apply-the-explicit-project-override-recommended-&quot;&gt;Step 2: Apply the Explicit Project Override (Recommended)&lt;/h3&gt;
&lt;p&gt;Instead of fighting global system environment variables that might break older projects on your machine, you can force this specific React Native project to use the correct runtime directory.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Navigate to your project's native android folder: &lt;code&gt;cd android&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Open the &lt;code&gt;gradle.properties&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;Add the following property at the bottom, supplying the absolute path you copied in Step 1:&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;org.gradle.java.home=/Insert/Your/Actual/Target/JDK/Path/Here&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Note: Windows users must escape backslashes in this file, format it like: &lt;code&gt;C:\\Program Files\\Java\\jdk-17&lt;/code&gt;)&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-align-android-studio-with-your-terminal&quot;&gt;Step 3: Align Android Studio with Your Terminal&lt;/h3&gt;
&lt;p&gt;A massive source of frustration is when the project builds fine in the terminal via CLI, but crashes the second you open it in Android Studio (or vice versa). You must align the IDE's build path:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open your project folder inside &lt;strong&gt;Android Studio&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Settings&lt;/strong&gt; (or &lt;strong&gt;Preferences&lt;/strong&gt; on macOS) &amp;gt; &lt;strong&gt;Build, Execution, Deployment&lt;/strong&gt; &amp;gt; &lt;strong&gt;Build Tools&lt;/strong&gt; &amp;gt; &lt;strong&gt;Gradle&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Locate the &lt;strong&gt;Gradle JDK&lt;/strong&gt; dropdown menu.&lt;/li&gt;
&lt;li&gt;Change it from the default system runtime to match the exact version your project requires (e.g., JDK 17 or JDK 21).&lt;/li&gt;
&lt;li&gt;Click Apply and hit &lt;strong&gt;Sync Project with Gradle Files&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-clear-the-aggressive-build-cache&quot;&gt;Step 4: Clear the Aggressive Build Cache&lt;/h3&gt;
&lt;p&gt;Gradle caches compilation footprints deeply. If you change your Java version without clearing the cache, Gradle will try to read the old, mismatched class files and throw the same error again. &lt;/p&gt;
&lt;p&gt;Wipe the execution deck completely by running these commands in your root directory:&lt;/p&gt;
&lt;h2 id=&quot;clean-the-native-android-directories&quot;&gt;Clean the native android directories&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;return-to-root-and-clear-bundler-caches&quot;&gt;Return to root and clear bundler caches&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd ..
npx react-native start --clear
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once the development server reboots and paths match up seamlessly across your terminal, properties file, and IDE, run your run command (npx react-native run-android or npx expo run:android) and the version mismatch error will be gone.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/19548/how-to-fix-gradle-error-unsupported-class-file-major-version-react-native-expo</guid>
<pubDate>Wed, 03 Jun 2026 08:49:58 +0000</pubDate>
</item>
<item>
<title>Fixing Gradle TLS handshake_failure on Maven Central (Android Builds)</title>
<link>https://coderlegion.com/19512/fixing-gradle-tls-handshakefailure-on-maven-central-android-builds</link>
<description>&lt;p&gt;If your Android native build is constantly failing with a TLS handshake error when trying to download dependencies from Maven Central, the root cause might be an invisible environment variable polluting your Java settings. &lt;/p&gt;
&lt;p&gt;The most frustrating part of this error is that your local internet connection is usually completely fine. &lt;/p&gt;
&lt;h3 id=&quot;the-quick-diagnosis&quot;&gt;The Quick Diagnosis&lt;/h3&gt;
&lt;p&gt;Open your terminal and test the connection directly to the Maven repository using curl:&lt;/p&gt;
&lt;p&gt;curl -I &lt;a rel=&quot;nofollow&quot; href=&quot;https://repo1.maven.org/maven2/&quot;&gt;https://repo1.maven.org/maven2/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If curl connects successfully and returns an HTTP 200, but your Gradle build still throws a &lt;code&gt;Received fatal alert: handshake_failure&lt;/code&gt;, your network is not the problem. Gradle itself is failing to establish the secure line.&lt;/p&gt;
&lt;h3 id=&quot;the-root-cause&quot;&gt;The Root Cause&lt;/h3&gt;
&lt;p&gt;An environment variable called &lt;code&gt;GRADLE_OPTS&lt;/code&gt; is likely injecting broken SSL or JSSE flags into your build process. Specifically, flags like &lt;code&gt;-Djavax.net.ssl.trustStoreType=WINDOWS-ROOT&lt;/code&gt; force Gradle to look at system-specific certificate stores that conflict with standard Java behavior, breaking the handshake completely.&lt;/p&gt;
&lt;h3 id=&quot;the-fix&quot;&gt;The Fix&lt;/h3&gt;
&lt;p&gt;To resolve this permanently, you need to wipe out the polluted configuration so Gradle can fall back to its clean JDK defaults.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open your system &lt;strong&gt;Environment Variables&lt;/strong&gt; settings.&lt;/li&gt;
&lt;li&gt;Check both the &lt;strong&gt;User variables&lt;/strong&gt; and the &lt;strong&gt;System/Machine variables&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Look for &lt;code&gt;GRADLE_OPTS&lt;/code&gt;. &lt;/li&gt;
&lt;li&gt;Delete the variable entirely from both locations.&lt;/li&gt;
&lt;li&gt;Close your IDE, restart your terminal, and run your build again.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Once those injected flags are gone, Gradle will use standard JDK defaults, and your dependency downloads will start succeeding immediately. &lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I've been spending a lot of my spare time building out a diagnostic tool over at &lt;a rel=&quot;nofollow&quot; href=&quot;https://fix-my-error.vercel.app&quot;&gt;FixMyError&lt;/a&gt; to map out environment bugs like this automatically. I just put a live, free sandbox preview on the homepage if you want to see how the engine breaks down terminal stack traces before you ever make an account.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/19512/fixing-gradle-tls-handshakefailure-on-maven-central-android-builds</guid>
<pubDate>Tue, 02 Jun 2026 15:56:09 +0000</pubDate>
</item>
<item>
<title>How to fix &quot;Gradle build daemon disappeared unexpectedly&quot; in React Native &amp; Expo</title>
<link>https://coderlegion.com/18873/how-to-fix-gradle-build-daemon-disappeared-unexpectedly-in-react-native-expo</link>
<description>&lt;p&gt;If you’ve been building mobile apps with React Native or Expo long enough, you’ve definitely hit that brick wall where a standard debug build works perfectly, but the moment you run &lt;code&gt;eas build&lt;/code&gt; or try to compile a production AAB/APK for the Play Store, the terminal throws a generic, frustrating crash:&lt;/p&gt;
&lt;p&gt;&quot;Gradle build daemon disappeared unexpectedly (it may have been killed or may have crashed)&quot;&lt;/p&gt;
&lt;p&gt;What makes this error so frustrating is that the terminal output rarely tells you &lt;em&gt;why&lt;/em&gt; the daemon vanished. &lt;/p&gt;
&lt;h4 id=&quot;the-root-cause-out-of-memory-oom-&quot;&gt;The Root Cause: Out-of-Memory (OOM)&lt;/h4&gt;
&lt;p&gt;When you trigger a release build, the Node process responsible for bundling your JavaScript files, assets, and third-party native modules spikes heavily in memory. By default, Android's JVM (Java Virtual Machine) doesn't allocate enough heap space to handle these massive compilation steps for modern, dependency-heavy apps. &lt;/p&gt;
&lt;p&gt;When the local or CI machine hits its limit, it forcefully kills the Gradle daemon to protect system stability. &lt;/p&gt;
&lt;h4 id=&quot;the-3-step-surgical-fix&quot;&gt;The 3-Step Surgical Fix&lt;/h4&gt;
&lt;p&gt;Instead of blindly deleting your &lt;code&gt;.gradle&lt;/code&gt; cache or reinstalling Android Studio, you can resolve this permanently by updating your configuration parameters.&lt;/p&gt;
&lt;h5 id=&quot;1-allocate-more-heap-space&quot;&gt;1. Allocate More Heap Space&lt;/h5&gt;
&lt;p&gt;Navigate to your project's &lt;code&gt;android/gradle.properties&lt;/code&gt; file and look for or append the &lt;code&gt;org.gradle.jvmargs&lt;/code&gt; line. Increase the maximum heap size (&lt;code&gt;-Xmx&lt;/code&gt;) to at least 4GB or 8GB depending on your app size:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;org.gradle.daemon=true
org.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=2048m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;2-configure-eas-build-if-using-expo-&quot;&gt;2. Configure EAS Build (If using Expo)&lt;/h5&gt;
&lt;p&gt;If you are running your builds via Expo Application Services (EAS), you need to tell the remote builders to utilize a larger resource class. Open your &lt;code&gt;eas.json&lt;/code&gt; file and specify a larger builder instance under your production profile:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;{
  &quot;build&quot;: {
    &quot;production&quot;: {
      &quot;android&quot;: {
        &quot;resourceClass&quot;: &quot;large&quot;
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;3-clear-and-reset&quot;&gt;3. Clear and Reset&lt;/h5&gt;
&lt;p&gt;Once the files are saved, kill any lingering zombie daemons and clear the old build artifacts so Gradle can start fresh:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew clean
./gradlew cleanBuildCache
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;taming-the-rest-of-your-terminal-logs&quot;&gt;Taming the rest of your terminal logs&lt;/h4&gt;
&lt;p&gt;Native Android and iOS build logs can easily stretch to hundreds of lines of total noise, hiding the true dependency mismatch. &lt;/p&gt;
&lt;p&gt;If you are currently fighting a different native module error or a breaking CocoaPods mismatch, I built a free developer utility called FixMyError that parses raw terminal dumps instantly. It strips out the conversational AI fluff and gives you the exact surgical terminal command or config change needed to unblock your build layout in 3 seconds.&lt;/p&gt;
&lt;p&gt;Give it a spin if you are currently stuck: &lt;a rel=&quot;nofollow&quot; href=&quot;https://fix-my-error.vercel.app&quot;&gt;Try FixMyError Free&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Let me know in the comments if increasing the JVM heap size successfully unblocked your production build!&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/18873/how-to-fix-gradle-build-daemon-disappeared-unexpectedly-in-react-native-expo</guid>
<pubDate>Mon, 25 May 2026 10:18:22 +0000</pubDate>
</item>
<item>
<title>Kotlin Gradle Plugin Errors: KAPT vs KSP Issues</title>
<link>https://coderlegion.com/18488/kotlin-gradle-plugin-errors-kapt-vs-ksp-issues</link>
<description>&lt;h2&gt;Why Kotlin Gradle Plugin Build Fails (Real Errors Developers Face)&lt;/h2&gt;
&lt;p&gt;If your Kotlin project suddenly stops building after a plugin update, fails only on iOS, or breaks after &lt;code&gt;gradle clean&lt;/code&gt; — you're not alone. These are common issues with the Kotlin Gradle Plugin that are rarely explained clearly.&lt;/p&gt;
&lt;p&gt;This guide breaks down real-world problems like &lt;strong&gt;kotlin multiplatform build fails&lt;/strong&gt;, &lt;strong&gt;kapt vs ksp issues&lt;/strong&gt;, and &lt;strong&gt;gradle cache causing build errors&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;Kotlin Multiplatform Build Fails on iOS but Works on JVM&lt;/h2&gt;
&lt;p&gt;One of the most frequent issues: your code compiles perfectly for JVM, but crashes or fails on iOS.&lt;/p&gt;
&lt;h3&gt;kotlin multiplatform iosArm64 unresolved references&lt;/h3&gt;
&lt;p&gt;This usually happens because of missing or broken native metadata. The JVM target is more forgiving, but iOS requires strict dependency resolution.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kotlin {
    iosArm64()
    sourceSets {
        val iosMain by getting {
            dependsOn(commonMain)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your &lt;code&gt;dependsOn&lt;/code&gt; graph is wrong, you'll get unresolved references even if the code is valid.&lt;/p&gt;
&lt;h3&gt;expect actual mismatch kotlin multiplatform error&lt;/h3&gt;
&lt;p&gt;Another common failure comes from mismatched &lt;code&gt;expect/actual&lt;/code&gt; declarations after a compiler or plugin update.&lt;/p&gt;
&lt;p&gt;Even a small signature change can break the build:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// commonMain
expect fun platformName(): String

// iosMain
actual fun platformName(): String = &quot;iOS&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If types or modifiers differ — build fails silently or throws cryptic errors.&lt;/p&gt;
&lt;h2&gt;KAPT vs KSP Issues in Kotlin (Annotation Processing Problems)&lt;/h2&gt;
&lt;p&gt;Mixing &lt;strong&gt;kapt and ksp in one project&lt;/strong&gt; is a classic source of instability.&lt;/p&gt;
&lt;h3&gt;kapt not working after kotlin update&lt;/h3&gt;
&lt;p&gt;KAPT often breaks after Kotlin version changes because it relies on internal compiler APIs.&lt;/p&gt;
&lt;p&gt;Typical symptoms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Annotation processors not running&lt;/li&gt;
&lt;li&gt;Generated code missing&lt;/li&gt;
&lt;li&gt;Random build failures&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;ksp vs kapt performance and stability&lt;/h3&gt;
&lt;p&gt;KSP is faster and more stable, but not all libraries support it. If you're mixing both, you can get conflicting generated sources.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;plugins {
    id(&quot;com.google.devtools.ksp&quot;)
    kotlin(&quot;kapt&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This setup often leads to non-deterministic builds.&lt;/p&gt;
&lt;h2&gt;Gradle Cache Issues Causing Kotlin Build Failures&lt;/h2&gt;
&lt;p&gt;Sometimes your code is fine — but Gradle cache is not.&lt;/p&gt;
&lt;h3&gt;gradle cache causing build errors kotlin&lt;/h3&gt;
&lt;p&gt;Gradle may reuse stale compiled artifacts, especially after plugin updates.&lt;/p&gt;
&lt;p&gt;That’s why builds fail only after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;gradle clean&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;switching branches&lt;/li&gt;
&lt;li&gt;updating dependencies&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;how to fix gradle build cache issues kotlin&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;./gradlew clean build --no-build-cache
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or fully reset:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rm -rf ~/.gradle/caches
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This forces a fresh dependency and compilation cycle.&lt;/p&gt;
&lt;h2&gt;Quick Fix Checklist for Kotlin Gradle Plugin Errors&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Check sourceSets dependsOn graph&lt;/li&gt;
&lt;li&gt;Verify expect/actual signatures&lt;/li&gt;
&lt;li&gt;Avoid mixing kapt and ksp if possible&lt;/li&gt;
&lt;li&gt;Clear Gradle cache after updates&lt;/li&gt;
&lt;li&gt;Align Kotlin and plugin versions&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Conclusion: Kotlin Build Errors Are Often Configuration Issues&lt;/h2&gt;
&lt;p&gt;Most Kotlin Gradle Plugin failures are not about your business logic — they come from configuration, cache, or tooling mismatches.&lt;/p&gt;
&lt;p&gt;If you want a deeper breakdown with working configs, real fixes, and debugging steps, I explained everything here:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://krun.pro/kotlin-gradle-plugins/&quot;&gt;krun.pro&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18488/kotlin-gradle-plugin-errors-kapt-vs-ksp-issues</guid>
<pubDate>Wed, 20 May 2026 21:21:12 +0000</pubDate>
</item>
<item>
<title>React Native Android Autolinking Issues: Common Problems and What to Check</title>
<link>https://coderlegion.com/14097/react-native-android-autolinking-issues-common-problems-and-what-to-check</link>
<description>&lt;p&gt;Autolinking in React Native is supposed to simplify native module integration—but when it breaks, it can be frustrating and time-consuming to debug.&lt;/p&gt;
&lt;p&gt;If your Android build is failing or a package isn’t being recognized, autolinking is often the root cause.&lt;/p&gt;
&lt;p&gt;Here are the most common issues and what you should check.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-package-not-properly-installed&quot;&gt;1. Package not properly installed&lt;/h3&gt;
&lt;p&gt;Before diving deeper, make sure the dependency is correctly installed.&lt;/p&gt;
&lt;p&gt;Check:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;package.json&lt;/code&gt; includes the library&lt;/li&gt;
&lt;li&gt;&lt;code&gt;node_modules&lt;/code&gt; contains it&lt;/li&gt;
&lt;li&gt;You’ve run:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install
# or
yarn install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If something feels off, delete and reinstall:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;rm -rf node_modules
npm install
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-missing-or-outdated-gradle-sync&quot;&gt;2. Missing or outdated Gradle sync&lt;/h3&gt;
&lt;p&gt;Autolinking depends on Gradle picking up native modules.&lt;/p&gt;
&lt;p&gt;Try:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then rebuild the project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx react-native run-android
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-incorrect-react-native-cli-detection&quot;&gt;3. Incorrect React Native CLI detection&lt;/h3&gt;
&lt;p&gt;Sometimes the React Native CLI doesn’t properly detect the module.&lt;/p&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx react-native config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check if your dependency appears under:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;dependencies&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;platforms.android&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If it’s missing, autolinking is not working correctly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-manual-linking-leftovers&quot;&gt;4. Manual linking leftovers&lt;/h3&gt;
&lt;p&gt;If the project was upgraded or previously used manual linking, conflicts can occur.&lt;/p&gt;
&lt;p&gt;Check:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;MainApplication.java&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;settings.gradle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;app/build.gradle&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Remove any old manual linking entries.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-incompatible-library-version&quot;&gt;5. Incompatible library version&lt;/h3&gt;
&lt;p&gt;Some libraries:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;don’t support your React Native version&lt;/li&gt;
&lt;li&gt;require additional setup&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Always verify:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the library’s documentation&lt;/li&gt;
&lt;li&gt;compatibility with your RN version&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-android-specific-configuration-missing&quot;&gt;6. Android-specific configuration missing&lt;/h3&gt;
&lt;p&gt;Some packages still require manual steps.&lt;/p&gt;
&lt;p&gt;Look for instructions like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;adding permissions in &lt;code&gt;AndroidManifest.xml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;modifying &lt;code&gt;build.gradle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;adding custom native code&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Autolinking doesn’t cover everything.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-caching-issues&quot;&gt;7. Caching issues&lt;/h3&gt;
&lt;p&gt;React Native and Gradle caching can cause stale builds.&lt;/p&gt;
&lt;p&gt;Try clearing everything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;watchman watch-del-all
rm -rf node_modules
rm -rf android/build
rm -rf android/app/build
npm install
cd android &amp;amp;&amp;amp; ./gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts-1&quot;&gt;Final thoughts&lt;/h3&gt;
&lt;p&gt;Autolinking issues usually come down to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;installation problems&lt;/li&gt;
&lt;li&gt;Gradle not syncing correctly&lt;/li&gt;
&lt;li&gt;leftover manual configurations&lt;/li&gt;
&lt;li&gt;version incompatibilities&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Instead of guessing, go step by step through these checks—you’ll usually find the issue faster.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;If you’re working a lot with React Native Android builds, you’ll notice most errors fall into a few recurring patterns like these.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/14097/react-native-android-autolinking-issues-common-problems-and-what-to-check</guid>
<pubDate>Mon, 06 Apr 2026 16:56:36 +0000</pubDate>
</item>
<item>
<title>Gradle build errors in React Native: what usually wastes the most time</title>
<link>https://coderlegion.com/14096/gradle-build-errors-in-react-native-what-usually-wastes-the-most-time</link>
<description>&lt;p&gt;If a React Native Android build fails and the output mentions Gradle, it is very easy to lose time in the wrong place.&lt;/p&gt;
&lt;p&gt;The word “Gradle” is so broad that it can hide very different root causes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;version mismatches&lt;/li&gt;
&lt;li&gt;plugin resolution failures&lt;/li&gt;
&lt;li&gt;dependency conflicts&lt;/li&gt;
&lt;li&gt;autolinking problems&lt;/li&gt;
&lt;li&gt;corrupted caches&lt;/li&gt;
&lt;li&gt;environment issues on the machine&lt;/li&gt;
&lt;li&gt;native setup mistakes after upgrades&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is why “Gradle build error” is often less of a diagnosis and more of a category.&lt;/p&gt;
&lt;p&gt;I’ve seen this kind of issue enough times to notice a pattern: the biggest time loss usually does not come from the bug itself. It comes from checking the wrong things first.&lt;/p&gt;
&lt;p&gt;This is the checklist I would use to avoid that.&lt;/p&gt;
&lt;h3 id=&quot;the-first-trap-treating-gradle-as-the-actual-problem&quot;&gt;The first trap: treating Gradle as the actual problem&lt;/h3&gt;
&lt;p&gt;When people say “I have a Gradle error,” they usually mean one of two things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the Android build failed somewhere inside the Gradle process&lt;/li&gt;
&lt;li&gt;the terminal output included a Gradle task name, plugin message, or stacktrace&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That does &lt;strong&gt;not&lt;/strong&gt; automatically mean Gradle itself is the real root cause.&lt;/p&gt;
&lt;p&gt;Very often, the actual issue is one of these:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;React Native / AGP / Kotlin / JDK versions do not align&lt;/li&gt;
&lt;li&gt;a plugin cannot be resolved&lt;/li&gt;
&lt;li&gt;a native library was added but not integrated correctly&lt;/li&gt;
&lt;li&gt;autolinking did not generate what the project expects&lt;/li&gt;
&lt;li&gt;some Firebase / Android dependency is conflicting with another one&lt;/li&gt;
&lt;li&gt;local machine setup is broken&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the first mental correction I make is:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Gradle is the place where the failure appears. It is not always the thing that is actually wrong.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-usually-wastes-the-most-time&quot;&gt;What usually wastes the most time&lt;/h3&gt;
&lt;h4 id=&quot;1-reading-the-last-line-instead-of-the-first-useful-one&quot;&gt;1. Reading the last line instead of the first useful one&lt;/h4&gt;
&lt;p&gt;One of the most common wastes of time is staring at the bottom of the output.&lt;/p&gt;
&lt;p&gt;The last line usually tells you that the build failed. It does not tell you why.&lt;/p&gt;
&lt;p&gt;The useful clue is often earlier:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the first plugin that could not be found&lt;/li&gt;
&lt;li&gt;the first unresolved dependency&lt;/li&gt;
&lt;li&gt;the first incompatible version&lt;/li&gt;
&lt;li&gt;the first task that failed for a concrete reason&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If I want to save time, I search for the &lt;strong&gt;first meaningful error&lt;/strong&gt;, not the most dramatic-looking one at the end.&lt;/p&gt;
&lt;h3 id=&quot;2-deleting-everything-before-understanding-the-category-of-failure&quot;&gt;2. Deleting everything before understanding the category of failure&lt;/h3&gt;
&lt;p&gt;Clearing caches can help sometimes.&lt;/p&gt;
&lt;p&gt;But many people immediately do all of this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;delete &lt;code&gt;.gradle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;clean the project&lt;/li&gt;
&lt;li&gt;reinstall node modules&lt;/li&gt;
&lt;li&gt;reinstall Android Studio&lt;/li&gt;
&lt;li&gt;reinstall the JDK&lt;/li&gt;
&lt;li&gt;reboot the machine&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sometimes that works. But often it just burns time and makes the debugging less focused.&lt;/p&gt;
&lt;p&gt;I only treat it as a cache/environment cleanup issue if the error actually points in that direction, such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;corrupted transforms&lt;/li&gt;
&lt;li&gt;temporary workspace move failures&lt;/li&gt;
&lt;li&gt;stale artifacts&lt;/li&gt;
&lt;li&gt;file locking&lt;/li&gt;
&lt;li&gt;download/certificate issues&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the problem is really version alignment or plugin resolution, deleting everything does not solve the core issue.&lt;/p&gt;
&lt;h3 id=&quot;3-ignoring-version-compatibility-across-the-android-toolchain&quot;&gt;3. Ignoring version compatibility across the Android toolchain&lt;/h3&gt;
&lt;p&gt;A lot of Gradle build errors in React Native are really compatibility problems between these pieces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;React Native version&lt;/li&gt;
&lt;li&gt;Gradle wrapper version&lt;/li&gt;
&lt;li&gt;Android Gradle Plugin version&lt;/li&gt;
&lt;li&gt;Kotlin version&lt;/li&gt;
&lt;li&gt;JDK version&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If one of those is too old, too new, or mismatched with the others, the build can fail even if your app code is completely fine.&lt;/p&gt;
&lt;p&gt;This is especially likely after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;upgrading React Native&lt;/li&gt;
&lt;li&gt;copying Android config from another project&lt;/li&gt;
&lt;li&gt;updating Firebase/native libraries&lt;/li&gt;
&lt;li&gt;mixing Expo-related config with bare React Native setup&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When the error looks structural, version alignment is one of the first things I check.&lt;/p&gt;
&lt;h3 id=&quot;4-looking-only-at-dependencies-and-ignoring-settings-gradle&quot;&gt;4. Looking only at dependencies and ignoring &lt;code&gt;settings.gradle&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Some failures are not about package versions at all.&lt;/p&gt;
&lt;p&gt;They are about plugin resolution and build structure.&lt;/p&gt;
&lt;p&gt;If the error mentions things like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;plugin not found&lt;/li&gt;
&lt;li&gt;included build not found&lt;/li&gt;
&lt;li&gt;configuration of the root project failed&lt;/li&gt;
&lt;li&gt;React Native Gradle plugin issues&lt;/li&gt;
&lt;li&gt;autolinking/setup task problems&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;then I go straight to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;android/settings.gradle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;root &lt;code&gt;build.gradle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;plugin management / includeBuild setup&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This matters a lot in newer React Native Android projects, where small mistakes in how the plugin is included can break the whole build.&lt;/p&gt;
&lt;h3 id=&quot;5-forgetting-what-changed-last&quot;&gt;5. Forgetting what changed last&lt;/h3&gt;
&lt;p&gt;This is one of the highest-value questions in debugging:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What changed right before the build started failing?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;added a native package&lt;/li&gt;
&lt;li&gt;updated React Native&lt;/li&gt;
&lt;li&gt;updated Firebase packages&lt;/li&gt;
&lt;li&gt;changed Android config&lt;/li&gt;
&lt;li&gt;changed Kotlin/Gradle/JDK versions&lt;/li&gt;
&lt;li&gt;modified signing or release setup&lt;/li&gt;
&lt;li&gt;switched Expo/native configuration&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A lot of time gets wasted when the current error is treated as if it appeared randomly.&lt;/p&gt;
&lt;p&gt;Usually it didn’t. Usually something moved.&lt;/p&gt;
&lt;p&gt;The shortest path is often to inspect the most recent meaningful change.&lt;/p&gt;
&lt;h3 id=&quot;6-blaming-firebase-too-early-when-the-issue-is-broader&quot;&gt;6. Blaming Firebase too early when the issue is broader&lt;/h3&gt;
&lt;p&gt;If the problem started after adding or changing Firebase, it is tempting to assume the whole issue is “a Firebase bug.”&lt;/p&gt;
&lt;p&gt;But many Firebase-related Gradle failures are really one of these:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;dependency mismatch&lt;/li&gt;
&lt;li&gt;version conflict&lt;/li&gt;
&lt;li&gt;plugin/config issue&lt;/li&gt;
&lt;li&gt;native setup not completed&lt;/li&gt;
&lt;li&gt;packaging conflict&lt;/li&gt;
&lt;li&gt;outdated Android build setup&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So even when Firebase appears in the output, I still treat the build as a full Android build-chain problem until proven otherwise.&lt;/p&gt;
&lt;h3 id=&quot;7-treating-all-native-modules-as-if-they-install-the-same-way&quot;&gt;7. Treating all native modules as if they install the same way&lt;/h3&gt;
&lt;p&gt;React Native projects can get fragile when native modules accumulate.&lt;/p&gt;
&lt;p&gt;Some libraries rely cleanly on autolinking. Others still depend on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;correct plugin setup&lt;/li&gt;
&lt;li&gt;Android manifest changes&lt;/li&gt;
&lt;li&gt;Gradle config&lt;/li&gt;
&lt;li&gt;native initialization&lt;/li&gt;
&lt;li&gt;version-specific setup steps&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the build started failing after adding a package, I do not just ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“Did I install it?”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“What exact Android expectations did this package add?”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That distinction saves a lot of guessing.&lt;/p&gt;
&lt;h3 id=&quot;8-confusing-machine-setup-problems-with-project-problems&quot;&gt;8. Confusing machine/setup problems with project problems&lt;/h3&gt;
&lt;p&gt;Some Gradle errors are about the project.&lt;/p&gt;
&lt;p&gt;Some are about the machine.&lt;/p&gt;
&lt;p&gt;That is a major distinction.&lt;/p&gt;
&lt;p&gt;I think “machine/setup issue” when the failure points toward things like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;missing SDK components&lt;/li&gt;
&lt;li&gt;JDK problems&lt;/li&gt;
&lt;li&gt;PATH/environment issues&lt;/li&gt;
&lt;li&gt;SSL/certificate/download failures&lt;/li&gt;
&lt;li&gt;local permissions&lt;/li&gt;
&lt;li&gt;file locking on Windows&lt;/li&gt;
&lt;li&gt;Gradle daemon state&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the project build files look normal but dependency downloads fail or local tools are inconsistent, I stop debugging app code and start checking the environment.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-mental-model&quot;&gt;A practical mental model&lt;/h3&gt;
&lt;p&gt;When a React Native Android build fails with a Gradle-heavy error, I sort it into one of these buckets first.&lt;/p&gt;
&lt;h4 id=&quot;bucket-1-build-toolchain-mismatch&quot;&gt;Bucket 1: Build toolchain mismatch&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Gradle wrapper&lt;/li&gt;
&lt;li&gt;AGP&lt;/li&gt;
&lt;li&gt;Kotlin&lt;/li&gt;
&lt;li&gt;JDK&lt;/li&gt;
&lt;li&gt;React Native version alignment&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;bucket-2-build-structure-plugin-resolution&quot;&gt;Bucket 2: Build structure / plugin resolution&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;settings.gradle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;plugin management&lt;/li&gt;
&lt;li&gt;includeBuild&lt;/li&gt;
&lt;li&gt;plugin not found&lt;/li&gt;
&lt;li&gt;autolinking structure&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;bucket-3-native-dependency-integration&quot;&gt;Bucket 3: Native dependency integration&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;newly added packages&lt;/li&gt;
&lt;li&gt;Firebase native setup&lt;/li&gt;
&lt;li&gt;Expo/native mix&lt;/li&gt;
&lt;li&gt;Android-specific install expectations&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;bucket-4-cache-local-environment&quot;&gt;Bucket 4: Cache / local environment&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;corrupted &lt;code&gt;.gradle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;locked files&lt;/li&gt;
&lt;li&gt;SDK/JDK issues&lt;/li&gt;
&lt;li&gt;SSL/download failures&lt;/li&gt;
&lt;li&gt;local machine inconsistency&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The goal is not to know the exact fix instantly.&lt;/p&gt;
&lt;p&gt;The goal is to identify the correct category as early as possible.&lt;/p&gt;
&lt;h3 id=&quot;what-i-would-not-do-first&quot;&gt;What I would not do first&lt;/h3&gt;
&lt;p&gt;I would not immediately:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;rewrite multiple Gradle files at once&lt;/li&gt;
&lt;li&gt;downgrade random packages&lt;/li&gt;
&lt;li&gt;reinstall everything on the machine&lt;/li&gt;
&lt;li&gt;clear every cache without reason&lt;/li&gt;
&lt;li&gt;assume the word “Gradle” is the real root cause&lt;/li&gt;
&lt;li&gt;ignore the last meaningful project change&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That usually increases noise instead of reducing it.&lt;/p&gt;
&lt;h3 id=&quot;my-practical-order-of-checks&quot;&gt;My practical order of checks&lt;/h3&gt;
&lt;p&gt;If I hit a React Native Android Gradle build error, this is the order I would usually follow:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;find the first meaningful error in the build output&lt;/li&gt;
&lt;li&gt;identify which category the failure belongs to&lt;/li&gt;
&lt;li&gt;check RN / Gradle / AGP / Kotlin / JDK compatibility&lt;/li&gt;
&lt;li&gt;inspect &lt;code&gt;settings.gradle&lt;/code&gt; and plugin resolution if the error is structural&lt;/li&gt;
&lt;li&gt;review the last package or config change&lt;/li&gt;
&lt;li&gt;check native module integration expectations&lt;/li&gt;
&lt;li&gt;only then treat it as a cache/environment cleanup problem if the error supports that direction&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That order usually saves more time than any specific trick.&lt;/p&gt;
&lt;h3 id=&quot;final-takeaway&quot;&gt;Final takeaway&lt;/h3&gt;
&lt;p&gt;The phrase “Gradle build error” often wastes time because it sounds more specific than it really is.&lt;/p&gt;
&lt;p&gt;Most of the real causes fall into repeatable patterns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;version mismatch&lt;/li&gt;
&lt;li&gt;plugin/build structure problem&lt;/li&gt;
&lt;li&gt;native integration issue&lt;/li&gt;
&lt;li&gt;cache/environment issue&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The faster you identify the category, the faster the fix becomes possible.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/14096/gradle-build-errors-in-react-native-what-usually-wastes-the-most-time</guid>
<pubDate>Mon, 06 Apr 2026 16:49:16 +0000</pubDate>
</item>
</channel>
</rss>