How Far Can Android Studio's Migration Agent Take iOS Code Into Kotlin? A Hands-On Evaluation
I fed one screen of my ukiyo-e wallpaper app to Android Studio's migration agent. What the generated Kotlin got right, the 12 fixes it needed, and the review workflow I now run before accepting any of it.
For close to a decade I have maintained the iOS and Android versions of my ukiyo-e wallpaper app as two separate native codebases. As an indie developer, implementing every feature twice had simply become part of life. So when Android Studio's migration agent was announced — a preview feature that analyzes React Native, web framework, or iOS code and migrates it to a native Kotlin app — my first reaction was not excitement. It was suspicion.
The pitch is that migrations which used to take weeks now take hours. That is not a claim I was willing to accept without testing it against my own code. So I carved out a single screen from a real app, handed it to the migration agent, and read every line of the Kotlin it produced. The short version: structural translation exceeded my expectations, lifecycle interpretation was exactly as dangerous as I feared, and the agent refuses to touch ads or billing at all. Each of those three layers deserves a closer look.
What "Weeks Become Hours" Actually Means — How the Migration Agent Works
The migration agent operates in three stages.
Analysis: it maps your screens, data flows, and dependencies, then produces a migration report
Planning: it proposes a per-screen, per-module migration plan, showing which UIKit constructs map to which Compose constructs
Generation: it outputs a Kotlin + Jetpack Compose project
It accepts three source families — React Native, web frameworks, and iOS — and I deliberately chose the hardest of the three to reason about: iOS to Kotlin. Google also shipped Android Bench alongside it, a benchmark for measuring how well LLMs handle Android development tasks. Releasing a migration tool and a public yardstick for judging its output at the same time reads, to me, as a statement of confidence.
One caveat before the details: this is a preview feature, and the behavior described here may change. Treat this as a field note from one indie developer's codebase in June 2026, not a permanent specification.
The Test Subject — One Category Screen From a Wallpaper App
I did not feed it the whole project. My standing rule is to hand agents work in units I can actually review, and the migration agent is no exception. Accepting hundreds of generated files before you understand the tool's habits leaves you with no realistic way to inspect any of them.
I picked the category grid screen because it concentrates everything typical about a small indie app into one place.
A UICollectionView grid with asynchronous image loading
A branch that shows or hides ad cells depending on purchase state (whether the user bought ad removal)
Lifecycle handling that re-checks purchase state every time the screen reappears
For the record: the input was roughly 2,100 lines of Swift including related classes. From analysis report to a building project took about 40 minutes, of which the agent's own processing was around 25. It generated 38 files. Measured on a single screen, "hours, not weeks" is not an exaggeration. The real question is not the clock time. It is what is inside those files.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can now judge which parts of an iOS-to-Kotlin migration the agent handles reliably and which parts always remain hand work, based on real generated output rather than marketing claims
✦You'll learn how to run a four-point acceptance review on generated Kotlin, including a ready-to-reuse Antigravity reviewer playbook
✦You will be able to decide whether the migration agent fits your project as a first-draft generator or as a way out of maintaining two native codebases
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
An Honest Read of the Generated Kotlin — Good Structure, Dangerous Lifecycles
Here is the heart of the original iOS implementation. The single most important behavior on this screen is the purchase-state re-check in viewWillAppear.
// CategoryGridViewController.swift — original iOS implementation (excerpt)final class CategoryGridViewController: UIViewController { private var categories: [WallpaperCategory] = [] private let repository = CategoryRepository() override func viewDidLoad() { super.viewDidLoad() configureCollectionView() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // Re-check purchase state on every appearance so ad cells // disappear immediately after the user buys ad removal // in Settings and navigates back Task { let isAdFree = await PurchaseState.shared.isAdFree() self.categories = try await repository.fetchCategories() self.applySnapshot(showAds: !isAdFree) } }}
And here is what the migration agent generated. At first glance it looks perfectly reasonable.
// CategoryGridScreen.kt — as generated by the migration agent (before fixes)@Composablefun CategoryGridScreen(viewModel: CategoryGridViewModel = viewModel()) { val uiState by viewModel.uiState.collectAsState() LaunchedEffect(Unit) { // Generated as the viewWillAppear equivalent — but this runs // exactly once, on first composition, not on every return viewModel.loadCategories() } LazyVerticalGrid(columns = GridCells.Fixed(2)) { items(uiState.categories) { category -> CategoryCard(category) } }}
There was plenty to like. Mapping UICollectionView with a diffable data source onto LazyVerticalGrid is the natural choice, the ViewModel separation is textbook, and the nullability it inferred from Swift's optionals was indistinguishable from hand-written Kotlin.
But the code above contains a difference that becomes a production bug. viewWillAppear fires every time the screen appears; the generated LaunchedEffect(Unit) runs once, on first composition. The consequence: a user buys ad removal in Settings, navigates back, and the ad cells are still there. It compiles, it works on first launch, and it only breaks under one specific navigation sequence — precisely the kind of difference code review misses most often.
Here is the corrected version.
// CategoryGridScreen.kt — after fixes: porting the *intent* of viewWillAppear@Composablefun CategoryGridScreen(viewModel: CategoryGridViewModel = viewModel()) { val uiState by viewModel.uiState.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { // Re-evaluate purchase state and categories on every return viewModel.refreshPurchaseStateAndCategories() } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } LazyVerticalGrid(columns = GridCells.Fixed(2)) { items(uiState.categories, key = { it.id }) { category -> CategoryCard(category) } }}
With this change, the "buy ad removal, go back, ads still visible" reproduction disappeared. Whether a tool can carry over the intent of a behavior rather than a syntactic mapping — in the end, the trustworthiness of automated migration condenses into that single question.
The 12 Fixes, Itemized
Across the 38 generated files, acceptance required hand edits in 12 places. The breakdown is worth recording.
Lifecycle interpretation: 3 fixes — the viewWillAppear issue above, plus variants (playback stopping in viewDidDisappear was not carried into onDispose, so a slideshow kept running in the background)
Dependency versions: 2 fixes — the agent added the latest Coil for image loading, which clashed with the Kotlin version of my existing modules and broke the build. The agent assumes a greenfield project and habitually picks the newest of everything
Hard-coded UI strings: 5 fixes — category-name prefixes and error messages were embedded in code instead of strings.xml. For a localized app, this is fatal if it slips through
Ads and billing stubs: 2 fixes — more on this below; it is a design decision rather than a defect
Measured in lines, I touched just under 200 of the roughly 3,800 generated lines — about 5 percent. The remaining 95 percent was usable as-is. But the fixes skew heavily toward the "compiles fine, behaves differently" category, so the small edit count is not the reassurance it appears to be.
Where the Agent Refuses to Go — Ads, Billing, and OS-Specific Concepts
What worried me most going in was how it would treat the wiring that produces revenue. The answer: AdMob initialization, the consent flow, and Play Billing purchase handling are all generated as stubs with TODO comments. There is no one-to-one Play Billing equivalent of StoreKit 2's Transaction.currentEntitlements, so this is genuinely a design decision, not a translation. Rewiring the billing code of four wallpaper apps to StoreKit 2 taught me the same lesson: in billing, "appears to work" is the most dangerous state there is.
For the same reason, concepts that exist only on Android — WallpaperManager being the obvious one for my apps — are not generated either. On iOS, setting a wallpaper means saving an image and asking the user to do the rest by hand; on Android you can implement the assignment directly. That asymmetry is new design work, not migration.
Honestly, I found this reassuring. I would much rather have the revenue wiring left explicitly open than have it "migrated" by inference and shipped to review without anyone noticing. The value of automation is not that it does everything — it is that the boundary of what it does is legible. The longer I work as a solo developer, the more I weight that property.
Making Acceptance Review Routine With Antigravity
Of the 12 fixes, I am confident I would have caught maybe half through eyeball review alone. The rest surfaced through fixed-viewpoint, mechanical sweeps — which I delegate to a review agent in Antigravity.
What made the review effective was refusing to be greedy about viewpoints. This is the relevant section of my AGENTS.md playbook, as it exists today.
## generated-kotlin-review — acceptance check for auto-migrated codeFor each generated .kt file, check exactly four things, in order.1. Lifecycle: does any LaunchedEffect(Unit) carry logic that assumes it re-runs on every screen return? Compare against the original iOS viewWillAppear / viewDidDisappear to confirm timing intent2. Dependencies: do library versions added to build.gradle.kts conflict with the existing modules' Kotlin / AGP versions?3. Strings: are user-visible strings hard-coded instead of living in strings.xml?4. Existing assets: does the code re-implement functionality that the existing AdManager / BillingManager already provides?Output per file, two values only: "OK" or "needs fix (one-linereason)" into report.md. Fixing is a separate task — never fixduring this review.
The load-bearing rule is the last one: review and repair must not share a task. The moment the reviewing agent is allowed to fix things, its report degrades into "I already took care of it," and the human loses the chance to judge the diff. I lifted this separation directly from the operations design I use for delegated dependency updates, and it transferred to migration acceptance without modification.
Which Projects Can You Hand Over? Three Axes
Generalizing from one screen deserves caution, but three judgment axes seem to hold.
Density of platform-specific APIs: the more billing, ads, notifications, widgets, and wallpaper-setting code an app carries, the narrower the agent's effective coverage. Apps that are mostly screens and data flow are the best fit
Whether an Android version already exists: if you already ship one, switching to generated code means discarding accumulated assets — crash fixes, device-specific workarounds — that never show up in line counts. If you are creating an Android version of an iOS-only app, a generated first draft is worth a great deal more
Remaining maintenance horizon: paying migration costs for an app you plan to sunset within a year or two makes no sense. Migration is an investment, and it needs a payback period attached
In my own case, I decided against replacing the ten-year-old Android codebase. Years of ad waterfall tuning and per-device fixes are an asset no line count reveals. But for a small iOS-only app I have been meaning to bring to Android, the first week of scaffolding work compressed into roughly half a day.
Verdict — Too Early to End Dual Maintenance, Better Than Expected as a First Draft
Before this test I was betting on "the output won't even run." Instead, structural translation landed well above my expectations, while the two things that keep an app trustworthy and solvent — lifecycle intent and revenue wiring — remained human work. That division of labor strikes me as healthy rather than disappointing.
If you try the migration agent, start with a single screen. Learn its habits — where it translates intelligently, where it mistakes intent — with your own eyes before you draft a full migration plan. There is no penalty for deciding slowly here. I hope these notes are useful to anyone else living with two codebases for the same app.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.