# Shipping iOS and Android at the same time > SwiftUI and Compose, two codebases, one physics core — why we ship iOS and Android together and how we keep them aligned. 2025-09-20 · 19 min read · engineering · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/cross-platform-two-codebases.html --- Every year someone asks why uranashel does not use Flutter, React Native, or Kotlin Multiplatform for the UI layer. Fair question. Five apps, three people, two platforms — the arithmetic looks bad. The answer starts with a deadline. Estua's audio callback must hand the hardware 512 samples every `512/48000 = 10.7 ms`, with no malloc and no locks, or the speaker clicks; that discipline has [its own post](https://uranashel.com/blog/dsp-audio-thread.html). Wheria fuses IMU data at 100 Hz. Sonarish runs a 4096-point FFT on microphone PCM while its meter redraws at 30 Hz. Code living under deadlines like these cannot sit behind a JavaScript bridge, and it cannot trust an abstraction that treats CoreMotion and SensorManager as interchangeable black boxes. They are different instruments that happen to live in similar glass slabs. They sample at different rates, apply different axis conventions, throttle background work differently, and fail in different garages. So we ship **SwiftUI** on iOS and **Jetpack Compose** on Android, plus shared logic modules in Kotlin and Swift wherever the math must produce identical numbers on both sides. ## Two UI codebases, one physical truth The split is deliberate and asymmetric. Navigation stacks, settings forms, AR overlays, spectrogram rendering — all of it lives natively on each platform, because that is where the platform-specific bugs live too. iOS 16+ ignores UINavigationBar title fonts; Android OEMs each ship their own audio buffer size. ARKit and ARCore disagree on coordinate handedness, so the same rotation matrix means two different things until somebody flips an axis. Fighting all this through a cross-platform framework means maintaining the framework's native shims anyway, plus the framework's own bugs, plus your app logic stacked on top of both. The math is the opposite case. The Kalman update, the step detector threshold, the FFT Hann window `w[n] = 0.5·(1 - cos(2πn/N))`, the pink-noise filter state inside Estua — every coefficient must match exactly across platforms. A user with an iPhone and a friend with a Pixel walking the same garage, past the same pillar E9 on floor B2, should see the same confidence badge at the same moment. That math lives in shared modules: Kotlin Multiplatform on the Android side, mirrored Swift on iOS, unit-tested on both against identical synthetic sensor inputs. The pipeline those modules implement is walked through in [the indoor navigation post](https://uranashel.com/blog/building-wheria-indoor.html). ## Where identical math stops being identical IEEE 754 is deterministic for addition, multiplication, and division: same inputs, same operation order, same bits, on any conforming CPU. Trouble arrives from three directions. First, transcendental functions. Android's libm and Apple's system math library both promise faithful rounding, yet occasionally round the same `sin(x)` to neighbouring doubles; our logs showed roughly 1 call in 400 differing by 1 ulp. Second, compilers fuse multiply-adds when optimization flags allow it, and a fused `a·b + c` rounds once where the unfused version rounds twice. Third, random numbers. Swift's SystemRandomNumberGenerator cannot be seeded at all, Kotlin's Random(seed) implements a different algorithm anyway, and a "shared" generative scene therefore diverges on the very first sample. Estua ships its own PCG32 inside the shared module for exactly this reason — seed 12345 has to mean the same waveform on both stores, as covered in [the non-repeating audio post](https://uranashel.com/blog/estua-non-repeating-audio.html). A 1-ulp error is physically meaningless, about `2e-16` relative. It still bit us. In one golden trace the compass covariance sat within `1e-9` of the unreliable-badge threshold for six consecutive frames; iOS crossed on frame 2141, Android on frame 2143. Same trace, same math, different badge for 20 ms. More precision would not have fixed a knife-edge comparison. We put hysteresis on the badge threshold, pinned FMA off in the shared modules, and replaced libm trig with our own polynomial approximation so the golden tests could assert exact equality. ## The golden-trace harness Parity between the two codebases is enforced by replay. We keep 214 recorded sensor traces (garage walks, desk shakes, an elevator ride, one motorbike commute across District 7), collected from 9 phones over two years. Every night a small CI rack replays all of them through the Swift pipeline on an iPhone 14 and the Kotlin pipeline on a Pixel 7, then compares outputs bit for bit. ``` // nightly parity job: one iPhone 14, one Pixel 7 for trace in goldenTraces { // 214 recorded runs let a = swiftPipeline.replay(trace) // heading, steps, floor, badge let b = kotlinPipeline.replay(trace) for (fa, fb) in zip(a, b) { assert(bits(fa) == bits(fb)) // bit-for-bit since the trig swap } } // audio side: render 60 s of Estua seed 12345 on both, // FFT the two renders, fail on any band differing by > 1 dB ``` Before the trig swap that assert had to be a tolerance, and tolerances rot. Someone widens one to get a release out the door, nobody remembers why, and 6 months later the two platforms disagree by a metre with every test green. Bit equality removes the grey zone. The test either passes or somebody broke determinism, and the diff points at the exact frame where they broke it. ## The release train and ktuyen's matrix atuan tags iOS and Android releases together: same version number, same changelog, same day on both stores whenever the review queues cooperate. That sounds bureaucratic until you see what it prevents — a compass fix shipping on iOS while Android still runs the old heading pipeline, or an LFO update reaching Estua on one platform and quietly breaking seed compatibility between two friends' phones. Before every release, ktuyen runs the device matrix: - iPhone 13 through 16, Pixel 6 through 9, one Samsung mid-range for OEM quirks - a full UI pass in Vietnamese and in English - an offline garage walk at Landmark-style and spiral-mall geometries - an overnight Estua soak test, listening for audio clicks - a Sonarish LAeq comparison between platforms with identical mic placement Every line carries a numeric threshold. The compass unreliable badge has to appear at the same covariance value on both OSes. A 15-minute LAeq window must match within 0.5 dB under identical conditions. And Estua seed 12345 renders on both platforms into two scenes that must be audibly identical, verified by a spectrum diff under 1 dB RMS. ## What a shared framework would actually save Some UI, honestly. A settings form written once is a settings form written once. Below that line the savings stop. Real-time audio in every framework eventually drops to AVAudioEngine on iOS and AudioTrack on Android, so the native callback gets written twice regardless. The CoreMotion-versus-SensorManager axis flips still need debugging on both sides. ktuyen still tests both platforms, because users own both. And every sensor feature we ship would first touch an API the framework has to wrap, which is the platform-surface-area half of the answer. We did try it properly. In early 2024 I built a Flutter prototype of the Sonarish meter screen: two devices, one weekend, a frame profiler. On a Pixel 6 the platform-channel round trip for 30 Hz meter updates averaged 1.8 ms, which is fine, and spiked past 20 ms during garbage collection, which is a visible stutter on a needle the user is actively watching. Add the standing tax of waiting for the framework to expose each new iOS API, of working around a platform-view interop bug from the far side of the bridge, of explaining to a user why their Android parking walk dropped magnetometer samples somewhere between the layers. We ran the honest accounting and two native codebases won. ## The price, itemized Every feature ships twice. So does every bug, occasionally on the same day. A one-word copy change lands in Localizable.xcstrings and again in values-vi/strings.xml. A font fix touches both AppFont.swift and AppTypography.kt, because SwiftUI Forms default to the 17 pt system font while Compose needs a defensive ProvideTextStyle wrapper; that whole saga is in [the Nunito post](https://uranashel.com/blog/nunito-on-every-pixel.html). The cost is real and permanent, and we re-derive it at every planning meeting. We keep paying because of who the users are. Roughly half our users in Vietnam are on Android and roughly half on iPhone. A parking app that works on one platform fails half the time someone recommends it. Shipping one platform well while neglecting the other is laziness disguised as focus, and we would rather pay the duplication tax than tell half our users their phone is unsupported. The on-device architecture that makes this whole sensor pipeline possible in the first place is covered in [the on-device-first post](https://uranashel.com/blog/on-device-first.html). --- uranashel · [Home](https://uranashel.com/) · [Apps](https://uranashel.com/apps.html) · [Lab](https://uranashel.com/lab.html) · [About](https://uranashel.com/about.html) · [Blog](https://uranashel.com/blog/) · [Developers](https://uranashel.com/developers/) · [API docs](https://uranashel.com/docs/) · [Privacy](https://uranashel.com/privacy.html) Machine-readable: [llms.txt](https://uranashel.com/llms.txt) · [sitemap.xml](https://uranashel.com/sitemap.xml) · [openapi.json](https://uranashel.com/openapi.json) · [API](https://uranashel.com/api/v1/)