Hello, Android: same scoring engine, native shell
Yayınlandı yazar The Language Level Check team
Today we shipped the Android version. From the user’s perspective it’s the same product as the iOS app. Pick a language, take 40 questions, get your CEFR level and a per-skill breakdown. Under the hood it’s a parallel implementation in Kotlin with Jetpack Compose, sharing nothing with the iOS code except the test content itself.
This post is about how we decided what to share and what to rewrite.
What’s shared
One thing, and it’s the important one: the test content. Both apps load the same versioned blueprint JSON files from the same content endpoint. The blueprints describe items, scoring weights, cut scores, and accept lists in a platform-agnostic way. Adding a question or fixing a typo updates both apps simultaneously.
That meant our content pipeline had to be platform-agnostic from the beginning, which forced us to keep the blueprint schema clean. In retrospect this is the architectural decision we’re happiest about. If the apps had drifted to different content formats, we’d be writing every fix twice forever.
What we rewrote
Everything else. Scoring engine, question runner, language picker, results screen, share sheet, settings, analytics integration. All native Kotlin running through Compose.
We considered Kotlin Multiplatform Mobile (KMM) for the scoring engine. Decided against it for three reasons.
The scoring engine is small. A few hundred lines on each platform. Sharing it would have cost us more in build complexity than the duplication does in maintenance.
The scoring logic is also load-bearing. If anything goes wrong, we want to debug it in the language we’re reading. Two clear native implementations beat one shared implementation we have to reason about through a build-system layer.
We have a blueprint-coverage test suite on each platform that drives every blueprint through the production scoring code with every authored answer. If the two engines ever diverge in behavior, the test suite tells us immediately.
So instead of sharing the code, we share the test contract. Both apps run the same coverage suite. As long as both pass, they score the same way for the same input.
What surprised us
- String localization on Android is more forgiving than on iOS. Android’s
strings.xmlwith<plurals>resource handles plural rules in a way that took us multiple iterations to get right on iOS usingString(localized:). We’d been ready for the opposite. - Encrypted SharedPreferences caused trouble. We initially stored the device ID through
EncryptedSharedPreferencesto be safe. Turned out to add startup overhead, runtime crash modes on some OEM Android variants, and a dependency we weren’t sure would be supported long-term. We moved the device ID to plain SharedPreferences for the v1.0.2 patch. The device ID isn’t sensitive and didn’t warrant the complexity. - The Play Store listing localizes more aggressively than the App Store one does. We wrote our store description once in English. Play’s review process automatically suggests translated variants for the languages where we publish. The iOS metadata, by contrast, is whatever you upload through your release tooling.
- First-run crash reports were dominated by emulators and a single OEM. Higher volume of emulator sessions than we’d expected. The OEM in question (we won’t name it) has its own version of WebView that misbehaves with one of our libraries. We worked around it for the v1.0.1 patch.
What’s the same as iOS, on purpose
Some things we deliberately kept identical to the iOS app even though Android idiom would have suggested otherwise:
- The question runner UI uses the same layout structure on both platforms. There’s a version of this app where we leaned into Material 3 more aggressively, but it would have meant the iOS and Android tests felt different, and a CEFR estimate is supposed to be reproducible across platforms.
- The early-exit logic is identical, same trigger conditions on both platforms.
- The fuzzy-boundary indicator on the results screen reads the same and triggers under the same conditions.
- The Report Issue button is in exactly the same place. We’ll rename it on Android to “Report problem” eventually, but for v1.0 we kept the wording identical for support consistency.
What’s next
The first Android-specific patch is already in flight. The v1.0.1 we mentioned above, with the encrypted-prefs fix and a couple of layout tweaks for foldables. After that we want to focus on Play Store listing quality. There’s a real gap between our App Store and Play Store install rates, and we suspect a good chunk of it is conversion on the store page itself.
Two platforms, one product, one content pipeline. Thanks for installing.