Skip to content
Back to journal
FlutterSource BuildToolchain

How to Build an Old Flutter Project That No Longer Compiles (Without Downgrading Your Machine)

CCode2Native EngineeringEngineering team
2026-07-24

If you have an older Flutter project — most 3.x releases from 2022 onward — that refuses to compile on your current machine (Namespace not specified, Unsupported class file major version 65, Kotlin plugin floor errors), the fastest path to a signed APK is to compile it on a toolchain from the project's own era, not to migrate the project to yours. Upload the project ZIP (or connect the Git repo) to a cloud build that auto-selects a matching Flutter/Gradle/JDK image, repairs the known scaffolding walls, and returns a signed APK + AAB. Nothing gets installed or downgraded on your machine, and the first build is free. One honest caveat up front: the era matrix reaches back to the Flutter 3.19 line, so genuinely ancient projects — Flutter 1.x/2.x on pre-null-safety Dart — usually need a short code migration first (covered near the end).

The project didn't rot. Your machine moved.

The code in a dormant Flutter repo is the same bytes it was the day it last built. What changed is everything around it: you (or a new laptop, or a fresh Android Studio install) now run a 2026 Flutter SDK, a 2026 Android Studio with its bundled JDK 21, and Android Gradle Plugin 8.x defaults. An Android build is a chain of five versioned tools that all have opinions about each other:

  • The Flutter SDK drives the build and enforces its own minimums for Gradle, AGP, and Kotlin.
  • The Gradle wrapper pinned in gradle-wrapper.properties — each Gradle release only understands JVM bytecode up to a certain version.
  • The Android Gradle Plugin pinned in your build files — each AGP release demands a minimum Gradle and a minimum JDK (AGP 8.0 made JDK 17 a hard floor).
  • The Kotlin Gradle plugin pinned as ext.kotlin_version or in settings.gradle.
  • The JDK that actually runs Gradle — modern Flutter finds Android Studio's bundled runtime, which is Java 21 in current releases.

A project scaffolded with Flutter 2.x in 2021 typically shipped a Gradle 6.7 wrapper and AGP 4.1. A 2022-era Flutter 3.0 project shipped roughly Gradle 7.4 and AGP 7.1. Point a 2026 toolchain at either one and the chain snaps at its oldest link — usually before Gradle even parses your build script. The classic first failure looks like this:

FAILURE: Build failed with an exception.

* Where:
Build file '.../android/build.gradle' line: 1

* What went wrong:
A problem occurred evaluating root project 'android'.
> BUG! exception in phase 'semantic analysis' in source unit
  '_BuildScript_' Unsupported class file major version 65

That "BUG!" is not a bug in your project. It is Gradle's Groovy compiler choking on bytecode newer than it was written to read. The major-version number is a decoder ring for which JDK is invoking Gradle: 65 = Java 21, 61 = Java 17, 55 = Java 11, 52 = Java 8. Old Gradle, new Java. Here is the full family of errors and what each one is actually telling you:

The errorWhat it meansThe mismatched pair
Unsupported class file major version 65Your JDK (21) is newer than the project's Gradle can parse — Gradle 7.5, for example, tops out at Java 18; Java 21 needs roughly Gradle 8.5+Machine JDK vs project's Gradle wrapper
Namespace not specifiedAGP 8+ requires a namespace in every module's build.gradle; pre-2023 projects and unmaintained plugins only declared package in the manifestModern AGP vs 2021–22-era module or plugin
Minimum supported Gradle version is 8.x. Current version is 7.ySomeone (you, or an upgrade tool) bumped AGP without bumping the wrapper it depends onAGP vs Gradle wrapper
Your project requires a newer version of the Kotlin Gradle pluginThe Flutter tool enforces a Kotlin floor that rises with each release; your pinned kotlin_version predates itInstalled Flutter SDK vs project's Kotlin pin
Could not find or load main class org.gradle.wrapper.GradleWrapperMaingradle-wrapper.jar is missing — a .gitignore from years ago silently dropped the binary from the repoWhat's in git vs what Gradle needs to bootstrap

Why fixing one error surfaces the next one

Every error above has a well-documented individual fix. The trap is that the fixes cascade. You bump the wrapper to Gradle 8.x so your JDK 21 can run it — and now Gradle drops support for your AGP 4.x. You bump AGP to 8.x — and now every module needs a namespace, JDK 17 becomes mandatory, jcenter() repositories are dead, and one of your pinned plugins — abandoned by its author in 2022 — fails with the namespace error inside its own build.gradle, which you don't control. The known workaround for that last one is a root-level patch that back-fills a namespace into every module that lacks one, derived from its manifest:

// android/build.gradle — workaround for unmaintained plugins on AGP 8+
subprojects {
    afterEvaluate { project ->
        if (project.hasProperty('android') && project.android.namespace == null) {
            def manifest = file(project.android.sourceSets.main.manifest.srcFile)
            def parsed = new groovy.xml.XmlSlurper().parse(manifest)
            project.android.namespace = [email protected]()
        }
    }
}

It works, and it is also the moment most people realize they are no longer "building an old project" — they are porting it. Two hours in, the honest local options are: finish the migration by hand (run flutter analyze --suggestions, walk the Gradle/AGP/Kotlin bumps, fix what breaks), or go the other direction and rebuild yesterday's environment — fvm to pin the old Flutter SDK, plus a matching JDK via flutter config --jdk-dir, plus the right Android SDK components. Both paths are legitimate. Both eat an afternoon you planned to spend shipping one small fix to an app that already works.

What actually happens when you upload it

Code2Native's source-build pipeline takes the second path — recreate the era — but in a disposable cloud workspace instead of on your machine. Mechanically, per build:

  • Era detection.The pipeline reads your project's own declarations — the environment: sdk: constraint in pubspec.yaml and telltale version pins like intl / flutter_localizations — and selects a container image from a version matrix of real toolchain eras. As of mid-2026 the matrix spans a Flutter 3.19-era image (Dart 3.3, seeds a Gradle 7.6.3 wrapper), a 3.24 era (Dart 3.5, Gradle 8.3), and the current line (Gradle 8.11). An old project lands on an old image; it is never forced through the newest SDK. You can also override the era per project.
  • Scaffolding repair, minimal and logged.Only the walls with exactly one correct answer are auto-fixed: a missing or truncated Gradle wrapper is seeded at the era's template version; AGP and Kotlin are raised to the floor the selected toolchain requires (floor, not latest); a missing namespace is derived from the manifest; compileSdk is raised when a dependency demands it — while targetSdk, which changes runtime behavior, is deliberately left alone. Every repair is listed in the build log. Your Dart code is never touched.
  • Build and sign. The default command is ./gradlew assembleRelease, run with a JDK the selected Gradle actually supports. Output is signed with your uploaded release keystore — reused for every rebuild of that project, so updates install cleanly over the version already on devices — or with a generated key if you don't have one. You get an APK for direct install and an AAB for Google Play.
  • Cleanup. The workspace is ephemeral and your source is deleted after the build — it is compiled, not retained. A failed build refunds its credit automatically.

Why era-matching beats brute force: in a July investigation of our own failed customer builds, nine out of nine dependency-conflict failures in the window resolved without a single code change once the toolchain era matched the project — mostly old intlpins that a 3.24-era SDK satisfies natively. The most extreme case we've written up is Google's own archived flutter/gallery repo, whose committed lockfile pins a win32 version using an API Dart 3.5 deleted — it compiles on a 3.19-era image and on nothing newer, no matter how much you patch.

If your build genuinely needs a custom entry point, a code2native.json beside your gradlew overrides the defaults:

{
  "artifacts": { "dir": "dist/build" },
  "commands": {
    "ANDROID": "./gradlew assembleRelease"
  }
}

Step by step: from dormant repo to signed APK

  1. Locate the real project root — the folder containing pubspec.yaml with an android/ directory next to lib/. Don't rename or restructure anything; the era detection wants the project as it last existed.
  2. Zip it, or skip the zip and connect the Git repo. If zipping, exclude build/ and .dart_tool/. Keep pubspec.lock — for an old project the lockfile is evidence, not clutter; it records the dependency world that actually worked. If your repo gitignored gradle-wrapper.jaryears ago, don't hunt for it — a missing wrapper is one of the auto-repairs.
  3. Create a source-build project on Code2Nativeand upload. Preflight checks run before anything is charged — an upload that isn't a buildable project is rejected up front, not failed after billing.
  4. Upload your release keystore if this app was ever shipped. Same keystore = same signature = the new APK installs over the old one and Google Play accepts the update. No keystore? One is generated and reused for the project from then on.
  5. Build.Watch the log if you're curious — it shows the selected era and each repair applied. A few minutes later: APK + AAB. If it fails, you get the real Gradle error and the credit back.

Honest comparison: four ways to revive an old Flutter build

Era-matched cloud builds are not the only sane option. Here is the real decision, including where the alternatives win:

FactorEra-matched cloud buildfvm + local downgradeUpgrade the projectPinned CI (Codemagic / GitHub Actions)
Time to a signed APKMinutesHours (SDK + JDK + Android SDK)Hours to daysAn afternoon of YAML + version bisecting
Who figures out the working version setThe pipelineYouYou (AGP Upgrade Assistant helps)You
Your machine afterwardsUntouchedMultiple SDKs/JDKs to maintainUntouched (project changes instead)Untouched
CostFirst build free; then 1 credit ($29/mo = 10)FreeFree (your time)Free tiers exist
Right for active development
Handles the JDK side, not just FlutterPartially — fvm pins Flutter onlyYes, once you've found the right pins

To be explicit about the concessions: fvm is excellentand free when the only mismatch is the Flutter SDK itself — its limit is that it doesn't manage the JDK or Android SDK halves of the chain. Upgrading the project is the correct long-term answer for an app under active development; an era-matched build is for when you need a working binary now, or the project is in maintenance mode and a multi-day migration buys you nothing. And pinned CI is exactly how teams solve this permanently — if you already know which version triple works and don't mind maintaining the YAML and signing secrets.

When not to use this

  • You're actively developing the app.You need a local toolchain for hot reload anyway — bite the bullet and migrate the project properly. Use the cloud build for the one-off "I just need a signed binary" moments.
  • The project is React Native, Ionic, or Expo. Those source builds are on a waitlist, not live — we won't pretend otherwise. This pipeline compiles Flutter, native Android (Gradle), and Capacitor projects that include an android/ folder.
  • The failure is in your Dart code, not the toolchain. A pre-null-safety project (Dart below 2.12) with discontinued plugins needs real migration work no toolchain selection can skip. Same story for AI-generated projects with broken scaffolding — related family, different disease.
  • You have working CI with pinned versions.Don't fix what isn't broken.
  • You need a store-ready iOS build. From source we produce an unsigned .ipa on Apple silicon (Pro plan and up) that you re-sign with your own Apple certificate — honest, but not a submit-and-done iOS pipeline.

FAQ

Will you modify my project files or upgrade my code?

The era match is the whole point: the pipeline changes the environmentto fit the project, not the other way around. The only edits are scaffolding repairs with one correct answer — wrapper seeding, AGP/Kotlin raised to the selected era's floor, namespace back-fill, compileSdk floor — each listed in the build log. Dart code and targetSdk are never touched.

How do you know which Flutter era my project needs?

From the project's own declarations: the Dart SDK constraint in pubspec.yaml plus era-revealing pins like intl (0.18 means the 3.19 line, 0.19 the 3.24 line, 0.20 current). If the automatic pick is wrong, you can pin the era per project in the dashboard.

My old app is live on Google Play. Can the rebuilt APK ship as an update?

Yes, if you upload the original release keystore — Android updates require the same signature, and your keystore is stored encrypted and reused for every rebuild of that project. If the original keystore is lost, that is a Google Play key-reset conversation with Google, not something any build service can route around.

How old a Flutter project can this build?

As of mid-2026 the era matrix reaches back to Flutter 3.19 (the early-2024 line, with a Gradle 7.6.3-era template). Most 2021–2023 projects build on the 3.19 or 3.24 era after the automatic scaffolding repairs. Genuinely ancient projects — Flutter 1.x/2.x with pre-null-safety Dart — usually need code-level migration first, and we'd rather tell you that than sell you a failed build (which auto-refunds anyway).

What happens to my source code after the build?

It is compiled in an ephemeral workspace and deleted afterward. We return the signed APK/AAB and the build log; we don't retain the source. Keystores are the exception — stored encrypted so rebuilds keep a consistent signature.

Can't I just fix this locally for free?

Absolutely — this post gave you the map: flutter analyze --suggestions to see the mismatches, ./gradlew wrapper --gradle-version 8.9 to bump the wrapper, the AGP Upgrade Assistant in Android Studio, the namespace patch above. If the project is worth days of your time, do that. The cloud path exists for when it isn't — and for the wall where the fix cascade forces a plugin or Dart migration you didn't sign up for. There's a broader walkthrough of the no-local-setup path in our Flutter APK cloud build guide.

One warning for the local route: run version bumps on a branch. The AGP Upgrade Assistant and "flutter create ." both rewrite files in place, and the pre-upgrade state — the one that used to build — is exactly what you'll want back if the migration goes sideways.

Your old project, compiled on its own era's toolchain.

Upload the ZIP or connect the repo. The pipeline picks the matching Flutter/Gradle/JDK era, repairs the scaffolding walls, and returns a signed APK + AAB. First build is free, and failed builds refund automatically.

C

Code2Native Engineering

Engineering team

Written by the Code2Native engineering team — the people who build and operate the cloud build pipeline.