Created
September 7, 2026 07:15
-
-
Save romanbsd/af4cd939c3a6e2057b36343b7218a7f0 to your computer and use it in GitHub Desktop.
Convert Flutter android build from Kotlin to Java + Gradle
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Convert a Flutter project's Android host from Kotlin to Java/Groovy. | |
| Deterministically: | |
| - MainActivity.kt -> MainActivity.java (moved kotlin/ -> java/) | |
| - *.gradle.kts -> *.gradle (Kotlin DSL -> Groovy DSL) | |
| The Kotlin Gradle plugin and kotlin{} block are KEPT: Flutter's own Gradle | |
| plugin requires KGP to be declared, and without it Flutter falls back to a KGP | |
| version below its own minimum and the build fails. So this removes the Kotlin | |
| *source* (MainActivity) but not the Kotlin toolchain. | |
| Only the trivial FlutterActivity MainActivity is converted; anything richer is | |
| left alone with a warning. Unrecognized gradle lines are passed through verbatim | |
| and warned about, never silently rewritten. | |
| Usage: | |
| python3 dekotlin.py [PROJECT_ROOT] # default: cwd | |
| python3 dekotlin.py --dry-run | |
| python3 dekotlin.py --self-test | |
| """ | |
| from __future__ import annotations | |
| import re | |
| import sys | |
| from pathlib import Path | |
| FLUTTER_ACTIVITY_IMPORT = "io.flutter.embedding.android.FlutterActivity" | |
| # Pinned, verified KGP version. "latest checked" = last version confirmed to | |
| # build here; bump it when you verify a newer one. Override with --kotlin-version. | |
| # ponytail: pinned, not fetched. A live lookup would break determinism; add one | |
| # only if you actually want the build to depend on the network. | |
| KOTLIN_PLUGIN_VERSION = "2.4.10" | |
| _KGP_LINE = re.compile(r'(id\s+"org\.jetbrains\.kotlin\.android"\s+version\s+")[^"]+') | |
| # Canonical Groovy for the Flutter settings.gradle flutter.sdk lookup. This block | |
| # is template boilerplate identical across Flutter projects; Kotlin's run{} has no | |
| # 1:1 Groovy line form, so we emit the official Groovy template for it. | |
| SETTINGS_FLUTTER_SDK_GROOVY = '''\ | |
| def flutterSdkPath = { | |
| def properties = new Properties() | |
| file("local.properties").withInputStream { properties.load(it) } | |
| def flutterSdkPath = properties.getProperty("flutter.sdk") | |
| assert flutterSdkPath != null, "flutter.sdk not set in local.properties" | |
| return flutterSdkPath | |
| }()''' | |
| MAIN_ACTIVITY_JAVA = '''\ | |
| package {pkg}; | |
| import io.flutter.embedding.android.FlutterActivity; | |
| public class MainActivity extends FlutterActivity {{ | |
| }} | |
| ''' | |
| # ponytail: brace matcher ignores braces inside strings/comments. Fine for the | |
| # tiny Flutter template files; revisit only if a gradle file embeds `{`/`}` in a | |
| # string literal. | |
| def _match_brace(text: str, open_idx: int) -> int: | |
| depth = 0 | |
| for i in range(open_idx, len(text)): | |
| if text[i] == "{": | |
| depth += 1 | |
| elif text[i] == "}": | |
| depth -= 1 | |
| if depth == 0: | |
| return i | |
| raise ValueError("unbalanced braces") | |
| # Per-line Kotlin-DSL -> Groovy-DSL rewrites. `x = y` property assignments are | |
| # left as-is: modern Groovy Gradle accepts them. | |
| LINE_RULES = [ | |
| (re.compile(r'tasks\.register<(\w+)>\(("[^"]+")\)'), r'tasks.register(\2, \1)'), | |
| (re.compile(r'\bid\("([^"]+)"\)'), r'id "\1"'), | |
| (re.compile(r'^(\s*)val\s+(\w+)\s*(?::\s*[\w.<>]+\s*)?='), r'\1def \2 ='), | |
| (re.compile(r'manifestPlaceholders\["([^"]+)"\]\s*=\s*(.+)'), | |
| r'manifestPlaceholders += [\1: \2]'), | |
| (re.compile(r'\.getByName\("([^"]+)"\)'), r'.\1'), | |
| ] | |
| # If any of these survive rewriting, the line is Kotlin we didn't understand. | |
| LEFTOVER_MARKERS = [ | |
| re.compile(r'\bval\b'), | |
| re.compile(r'\brun\s*\{'), | |
| re.compile(r'getByName'), | |
| re.compile(r'register<'), | |
| re.compile(r'\["'), | |
| ] | |
| def convert_gradle_text(text: str, kotlin_version: str = KOTLIN_PLUGIN_VERSION) -> tuple[str, list[str]]: | |
| warnings: list[str] = [] | |
| # settings.gradle flutter.sdk run{} block -> canonical Groovy | |
| m = re.search(r'(?m)^([ \t]*)val\s+flutterSdkPath\s*=\s*\n?\s*run\s*\{', text) | |
| if m: | |
| open_idx = text.index("{", m.start()) | |
| close_idx = _match_brace(text, open_idx) | |
| text = text[:m.start()] + SETTINGS_FLUTTER_SDK_GROOVY + text[close_idx + 1:] | |
| out_lines: list[str] = [] | |
| for line in text.splitlines(): | |
| for pat, repl in LINE_RULES: | |
| line = pat.sub(repl, line) | |
| for marker in LEFTOVER_MARKERS: | |
| if marker.search(line): | |
| warnings.append(f"unconverted line kept verbatim: {line.strip()}") | |
| break | |
| out_lines.append(line) | |
| result = "\n".join(out_lines) | |
| if text.endswith("\n"): | |
| result += "\n" | |
| result = _KGP_LINE.sub(r"\g<1>" + kotlin_version, result) # pin KGP version | |
| return result, warnings | |
| def _is_trivial_main_activity(body_lines: list[str]) -> bool: | |
| """True if the file is nothing but a bare `class MainActivity : FlutterActivity()`.""" | |
| meaningful = [] | |
| for ln in body_lines: | |
| s = ln.strip() | |
| if not s or s.startswith("//") or s.startswith("/*") or s.startswith("*"): | |
| continue | |
| if s.startswith("package ") or s == "{" or s == "}": | |
| continue | |
| if s == f"import {FLUTTER_ACTIVITY_IMPORT}": | |
| continue | |
| meaningful.append(s) | |
| return meaningful == ["class MainActivity : FlutterActivity()"] or \ | |
| meaningful == ["class MainActivity : FlutterActivity() {"] | |
| def convert_main_activity(kt_path: Path, android: Path, dry_run: bool) -> list[str]: | |
| text = kt_path.read_text() | |
| m = re.search(r'(?m)^\s*package\s+([\w.]+)\s*$', text) | |
| if not m: | |
| return [f"{kt_path}: no package declaration; skipped"] | |
| pkg = m.group(1) | |
| if not _is_trivial_main_activity(text.splitlines()): | |
| return [f"{kt_path}: MainActivity has custom code; convert it to Java by hand"] | |
| java_path = android / "app/src/main/java" / Path(*pkg.split(".")) / "MainActivity.java" | |
| print(f" {kt_path.relative_to(android.parent)} -> {java_path.relative_to(android.parent)}") | |
| if dry_run: | |
| return [] | |
| java_path.parent.mkdir(parents=True, exist_ok=True) | |
| java_path.write_text(MAIN_ACTIVITY_JAVA.format(pkg=pkg)) | |
| kt_path.unlink() | |
| # prune now-empty kotlin/ dirs | |
| for parent in list(kt_path.parents): | |
| if parent.name == "kotlin" or parent == android: | |
| break | |
| if parent.is_dir() and not any(parent.iterdir()): | |
| parent.rmdir() | |
| kotlin_root = android / "app/src/main/kotlin" | |
| if kotlin_root.is_dir() and not any(kotlin_root.rglob("*")): | |
| for p in sorted(kotlin_root.rglob("*"), reverse=True): | |
| p.rmdir() | |
| kotlin_root.rmdir() | |
| return [] | |
| def run(root: Path, dry_run: bool, kotlin_version: str) -> int: | |
| android = root / "android" | |
| if not android.is_dir(): | |
| print(f"error: no android/ directory under {root}", file=sys.stderr) | |
| return 1 | |
| warnings: list[str] = [] | |
| kt_files = list(android.rglob("*.kt")) | |
| main_activities = [p for p in kt_files if p.name == "MainActivity.kt"] | |
| print("MainActivity:") | |
| if not main_activities: | |
| print(" (none found)") | |
| for kt in main_activities: | |
| warnings += convert_main_activity(kt, android, dry_run) | |
| print("Gradle:") | |
| for kts in android.rglob("*.gradle.kts"): | |
| converted, warns = convert_gradle_text(kts.read_text(), kotlin_version) | |
| gradle_path = kts.with_suffix("") # drop .kts -> .gradle | |
| print(f" {kts.relative_to(root)} -> {gradle_path.relative_to(root)}") | |
| warnings += [f"{kts.name}: {w}" for w in warns] | |
| if not dry_run: | |
| gradle_path.write_text(converted) | |
| kts.unlink() | |
| if warnings: | |
| print("\nWARNINGS:") | |
| for w in warnings: | |
| print(f" ! {w}") | |
| if dry_run: | |
| print("\n(dry run — no files written)") | |
| return 0 | |
| # --- self-test --------------------------------------------------------------- | |
| def _self_test() -> int: | |
| settings = '''pluginManagement { | |
| val flutterSdkPath = | |
| run { | |
| val properties = java.util.Properties() | |
| file("local.properties").inputStream().use { properties.load(it) } | |
| val flutterSdkPath = properties.getProperty("flutter.sdk") | |
| require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } | |
| flutterSdkPath | |
| } | |
| includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") | |
| } | |
| plugins { | |
| id("dev.flutter.flutter-plugin-loader") version "1.0.0" | |
| id("com.android.application") version "9.1.0" apply false | |
| id("org.jetbrains.kotlin.android") version "2.1.0" apply false | |
| } | |
| include(":app") | |
| ''' | |
| out, warns = convert_gradle_text(settings) | |
| assert f'id "org.jetbrains.kotlin.android" version "{KOTLIN_PLUGIN_VERSION}" apply false' in out, out | |
| # explicit override wins | |
| out2, _ = convert_gradle_text(settings, kotlin_version="9.9.9") | |
| assert 'org.jetbrains.kotlin.android" version "9.9.9"' in out2, out2 | |
| assert "run {" not in out and "val " not in out, out | |
| assert 'assert flutterSdkPath != null, "flutter.sdk not set' in out, out | |
| assert 'id "com.android.application" version "9.1.0" apply false' in out, out | |
| assert not warns, warns | |
| root_build = '''val newBuildDir: Directory = | |
| rootProject.layout.buildDirectory.dir("../../build").get() | |
| tasks.register<Delete>("clean") { | |
| delete(rootProject.layout.buildDirectory) | |
| } | |
| ''' | |
| out, warns = convert_gradle_text(root_build) | |
| assert "def newBuildDir =" in out, out | |
| assert 'tasks.register("clean", Delete) {' in out, out | |
| assert not warns, warns | |
| app_build = '''plugins { | |
| id("com.android.application") | |
| } | |
| android { | |
| defaultConfig { | |
| manifestPlaceholders["appAuthRedirectScheme"] = "ai.jobsteward.app" | |
| } | |
| buildTypes { | |
| release { | |
| signingConfig = signingConfigs.getByName("debug") | |
| } | |
| } | |
| } | |
| kotlin { | |
| compilerOptions { | |
| jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 | |
| } | |
| } | |
| ''' | |
| out, warns = convert_gradle_text(app_build) | |
| assert "kotlin {" in out and "JvmTarget.JVM_17" in out, "kotlin block must be kept" | |
| assert 'id "com.android.application"' in out, out | |
| assert 'manifestPlaceholders += [appAuthRedirectScheme: "ai.jobsteward.app"]' in out, out | |
| assert "signingConfig = signingConfigs.debug" in out, out | |
| assert not warns, warns | |
| # unknown Kotlin is passed through and warned, not mangled | |
| weird = 'println(buildConfig["someKey"])\n' | |
| out, warns = convert_gradle_text(weird) | |
| assert warns, "expected a warning for unconverted line" | |
| assert "buildConfig[" in out, "line must be preserved verbatim" | |
| assert _is_trivial_main_activity([ | |
| "package ai.jobsteward.jobsteward", "", | |
| f"import {FLUTTER_ACTIVITY_IMPORT}", "", | |
| "class MainActivity : FlutterActivity()"]) | |
| assert not _is_trivial_main_activity([ | |
| "package x", "class MainActivity : FlutterActivity() {", | |
| " override fun configureFlutterEngine() {}", "}"]) | |
| print("self-test: OK") | |
| return 0 | |
| def main() -> int: | |
| args = sys.argv[1:] | |
| if "--self-test" in args: | |
| return _self_test() | |
| dry_run = "--dry-run" in args | |
| kotlin_version = KOTLIN_PLUGIN_VERSION | |
| for a in args: | |
| if a.startswith("--kotlin-version="): | |
| kotlin_version = a.split("=", 1)[1] | |
| positional = [a for a in args if not a.startswith("--")] | |
| root = Path(positional[0]) if positional else Path.cwd() | |
| return run(root.resolve(), dry_run, kotlin_version) | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment