Last month we shipped the Polymarket Android app for the US market. It's live on Google Play. It's built with Compose and Navigation3, and it looks and feels like a modern Android app.
It's also mostly written in Swift.
Nearly 170,000 lines of production Swift run inside it: our models, our networking, our business logic, and more than 120 ViewModels. The same code powers our iOS app. One codebase, two apps.
Here's how we did it, what broke along the way, and why we'd do it again.
The question that started it
When we got the green light to launch in the US, our iOS app was well underway. The Android app didn't exist.
The obvious question was "how fast can we ship Android?" We asked a different one: once Android ships, how do we avoid maintaining two products that slowly drift apart?
Every team that runs two native apps knows the drift. A bug fixed on one platform and forgotten on the other. A pricing rule that quietly disagrees between the two. Two teams building the same feature twice, slightly differently.
So we made a bet on Skip, a toolchain that compiles Swift for Android and bridges it to Kotlin. Within a week, our existing Swift networking and models were powering a list of NBA games inside an Android app. That was enough proof to keep going.
Drawing the line
We had a head start: the iOS app was 100% Swift, split into packages with Swift Package Manager. The first move was structural. Everything we wanted to share went into polymarket-shared. Everything iOS-only stayed in polymarket-ios.
The rule was simple: share everything we can.
Anything touching UIKit, SwiftUI, MapKit, PassKit, or platform-specific SDKs stayed on the iOS side. Everything else was a candidate for the shared layer.
To make that boundary real, we hid platform code behind protocols and injected the right implementation at startup, using Point-Free's swift-dependencies.
Take payments. Apple Pay only exists on iOS:
// In polymarket-ios
class ApplePayClient {
func requestPayment(amount: Double) async throws -> ApplePayResult { ... }
}
To reuse the surrounding logic with Google Pay, the shared package defines a protocol:
// In polymarket-shared
protocol PlatformPayClient {
func requestPayment(amount: Double) async throws -> PlatformPayResult
}
enum PlatformPayResult {
case applePay(...)
case googlePay(...)
}
And here's the part that still feels a little like magic: the Android implementation is plain Kotlin, using the native Google Pay SDK, conforming to a Swift protocol.
class GooglePayClient(
val context: Context,
) : PlatformPayClient {
override suspend fun requestPayment(amount: Double): PlatformPayResult { ... }
}
On iOS, the AppDelegate injects the Apple Pay client. On Android, the Application class injects the Google Pay one. From then on, shared code asks for platformPay and gets whatever platform it's running on.
We repeated this pattern for analytics, storage, notifications, and authentication. Once those boundaries were in place, our domain logic depended on nothing but Foundation, Observation, and a small set of Swift packages that already run everywhere. Which means it ran on Android as-is.
Then we got greedy: sharing ViewModels
Shared models and networking are table stakes. Once tests were passing on Android, we asked the question that actually changes the economics:
Could we share the ViewModels too?
Our app uses our own flavor of MVVM. Every ViewModel is a plain Swift class marked @Observable, built on a small shared base class. The UI sends user actions in through an input enum, reads state back out through observable properties, and navigation flows out through callbacks. No UIKit. No SwiftUI. Just state and behavior.
That's what made the idea possible, because an @Observable object doesn't care who renders it:
- UIKit, which is most of our iOS app. Here we follow the convention of reading the state in
viewWillLayoutSubviewsorupdateProperties. - SwiftUI, on the screens where we use it. Observation is built in.
- Compose, on Android. Screens read the same properties, and Skip's observation bridge triggers recomposition when they change.
One ViewModel. Three UI frameworks. Each platform renders it natively, and none of them can drift apart on behavior, because there's nothing to drift: it's the same object.
Getting there took real work. Our ViewModels were built on ObservableObject, lots of code expected @Published properties and Combine publishers, and the ViewModels lived in the iOS package. Each one had to move into the shared module with its dependencies and migrate to @Observable. That migration paid off on iOS too: @Observable gave us a cleaner, faster foundation for both UIKit and SwiftUI.
So, to prove this approach, we picked a single screen to prove the idea end to end: Settings.
A week later, Settings was running on iOS and Android from the same ViewModel. That was the moment this stopped being a demo.
From there we moved screen by screen. Every migration merged into main and shipped to iOS users in the normal release cycle. The risk stayed small, iOS got healthier, and the pool of shared product logic kept growing.
Shipping the teaser first
One question we refused to answer on launch week: what does an Android release actually look like when Swift is in the stack?
So we shipped a teaser first, the Polymarket waitlist app on Google Play. It sounds trivial, but it exercised the whole machine: CI, Android packaging, shared Swift code, Skip bridging, analytics, and release mechanics. It was the smallest thing we could put in users' hands that still proved the system.
Once the numbers looked healthy, we iterated screen by screen and integration by integration until we had the app we wanted to launch.
The payoff during those months was compounding. While the Android team ported and integrated, the iOS team kept shipping features and fixes, and Android inherited most of that work for free, because the shared core kept moving forward underneath both apps.
What bit us
Not everything worked on the first try. Three problems cost us real time.
Object lifetimes
The hardest problem: Swift @Observable objects don't naturally fit Android's lifecycle model.
On iOS, object lifetime is predictable. Present a sheet, and its ViewModel is created just before it appears and destroyed when it's dismissed. Push a screen, same thing. Our shared ViewModels were designed around that behavior.
Compose is happy to keep objects around and reuse them later. In most Android apps that's a feature. In ours it caused stale data, zombie websocket subscriptions, strange recomposition behavior and more bugs.
After a few failed patches, we did the honest thing and built our own Compose components that recreate iOS lifetime behavior: a navigation stack, a tab view, a sheet, and a full screen cover, plus a swiftViewModel helper that creates a fresh ViewModel for each new screen or presentation and tears its scope down on dismissal.
We still don't control when Android's garbage collector frees a Swift-backed object. But we do control when a fresh ViewModel is created, and that's what the mental model depends on. Porting features got much more predictable after this.
Combine, on its way out
When this project started, much of our structural code was built on Combine, and OpenCombine runs it fine on Android. The catch is the bridge: Skip generates Kotlin APIs from Swift Concurrency, not from Combine publishers. So anything Android needed to call directly got a thin layer translating Combine into async/await. Something like this:
// In polymarket-shared
class PriceFeed {
// Combine works fine on both platforms,
// but Kotlin can't see this
let prices: AnyPublisher<Price, Never>
// So we expose a Swift Concurrency surface,
// which Skip bridges to Kotlin
var priceUpdates: AsyncStream<Price> {
prices.stream()
}
}
The mirror image showed up on iOS. As more code moved to Swift Concurrency and @Observable, older views still wanted publishers, so we wrote glue in the other direction too, exposing @Observable properties as publishers where needed.
Today, Combine is the exception rather than the rule. New code reaches for Swift Concurrency, Observation, and swift-async-algorithms first, and Combine survives only in a few corners we haven't migrated yet. Every migration shrinks the glue, and the goal is to remove it entirely.
The human problem
The last lesson was about people. Once a shared layer exists, it's only time before someone in your team imports UIKit into the shared code on a Friday afternoon. The fix isn't vigilance, it's CI: every pull request that touches shared code runs the shared test suite on an Android emulator and builds the Android app before it can merge. With that guardrail in place, the team started writing platform-neutral Swift by default.
What day-to-day development looks like
A fair question at this point: what is it actually like to work in this codebase?
If you're an iOS engineer, nothing changes. You write Swift in Xcode, and the shared package is a normal Swift Package Manager dependency.
If you're an Android engineer, the app looks like any other Android project: Kotlin, Compose, Gradle, Android Studio. The shared Swift arrives as prebuilt Android libraries. A tool called skip export compiles each shared Swift module into an AAR, and Gradle consumes those like any other dependency.
So a Kotlin change follows the normal loop: edit, build, run. A Swift change adds one step:
- Edit the Swift code in the shared package.
- Run
skip export. It recompiles only the modules that changed and drops fresh AARs into the Android project. - Sync Gradle, then build and run as usual.
In practice, Android engineers spend most of their time in Compose, building screens against ViewModels that already exist. When they do need to change Swift, the extra step is annoying but mechanical.
Swift on Android: a field report
Swift has run on Linux for a decade, but open-source Foundation is still young, and running Swift on Android at this scale is new territory. We've hit issues, reported them to the Android Workgroup, and watched things improve in real time.
Honestly, more worked than we expected. Localized strings, logging, bundled resources, @Observable tracking in Compose, Keychain, FileManager, testing: the breadth of what Skip handles surprised us more than once. The ecosystem is pulling in the right direction too. Point-Free's swift-dependencies, swift-clocks, swift-concurrency-extras, and swift-identified-collections, Apple's swift-async-algorithms and swift-crypto, and Google's gRPC all made this path far more practical than it would have been even a year ago.
The rough edges, especially from an Android engineer's chair:
- The edit-compile cycle. That extra
skip exportstep adds up. You feel it most when iterating on Swift changes from Android Studio, where the IDE can't do it for you. - Debugging. You can't set a breakpoint in Swift from Android Studio. Yet.
- Binary size. Swift adds real weight to the APK, about 50 MB at download time for us.
- Closure bridging. Bridging Swift closures to Kotlin was far harder than we expected. This is where we truly pushed the limits of the toolchain, because Swift closures are not simple.
Would we do it again? Without hesitation. Every alternative meant giving up Swift for the domain logic we're good at writing, and walking away from the well over 100,000 lines of useful Swift we had already written when we started.
Where this goes
The Android app is close to feature parity with iOS, and we keep shipping updates to make it feel more at home on Android. Both apps draw from the same core.
We're proud sponsors of Skip, and we'd recommend this path to any team with strong iOS roots that wants to keep writing Swift without giving up on Android.
Finally: thank you to Pierluigi, Bishwa and Pedro from our Android team, Marc from Skip, and the Swift Android Workgroup for making Swift on Android not just possible, but practical.