Skip to content

Instantly share code, notes, and snippets.

@romanbsd
Created September 7, 2026 07:15
Show Gist options
  • Select an option

  • Save romanbsd/af4cd939c3a6e2057b36343b7218a7f0 to your computer and use it in GitHub Desktop.

Select an option

Save romanbsd/af4cd939c3a6e2057b36343b7218a7f0 to your computer and use it in GitHub Desktop.
Convert Flutter android build from Kotlin to Java + Gradle
#!/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