The story behind an exam prep app built with Kotlin Multiplatform and Compose Multiplatform
Part 1 of the series “Building an Exam Prep App with Kotlin Multiplatform”
More and more companies are adopting Kotlin Multiplatform (KMP) for their mobile apps. The main motivation is to share business logic across platforms.
If you’re a mobile developer working on a product with separate native Android and iOS apps, you’ve probably noticed inconsistencies between the two, in business logic, UI/UX and other areas. These differences are often hard to remove once they exist.
One way to avoid them is to share the same business logic and UI across both platforms, and keep only the platform-specific parts native. That’s where KMP and Compose Multiplatform (CMP) shine.
Who This App Is For
A few months ago, I took the “Leben in Deutschland” test (“Life in Germany”). It’s a required step when you apply for permanent residency or citizenship in Germany. While preparing, I tried several apps and websites, but none of them really fit what I needed. So I decided to build my own.
The result is Leben in DE: Einbürgerungstest, a free Android app with all 460 official questions, mock exams in the real format, and localization. It works offline and doesn’t need an account.
This article is the story behind it, plus the lessons and tips I picked up along the way.

Study mode: each question and answer with an English translation

Vocabulary Refresher: key German words from the question

Settings: themes, language and your federal state
Why I Built It
The reason is twofold.
- First, to learn Kotlin Multiplatform and get hands-on experience by building and shipping a real consumer-facing app. I read a lot of blogs and docs on KMP, watched tutorials on YouTube; however, none of it really clicked until I started building things on my own.
- Second, to build something of my own: from planning to execution to shipping the product.
The Journey in Short
- February 2026: registered for the exam, planned the app and set up the project.
- March: built the CI/CD pipeline (automated builds on every change): unit tests, signed release builds, and the other plumbing a real release needs.
- April: built the first MVP (minimum viable product) with the core features: practice questions, mock exams and progress tracking.
- May: added sign-in and cloud sync with Supabase (a hosted Postgres database with a login service), and subscriptions with RevenueCat (a service that handles in-app purchases with Google Play and the App Store). Everything worked end to end.
- June: ran a closed test on Google Play with a small group of testers. I also used the app a lot to prepare for my own exam. The short mock exams were especially useful, and I ended up scoring 100%. :)
- July: based on what I learned, decided to launch for free and split the app into two versions.
- August: did the split. The free version became lean, and sign-in, sync and subscriptions moved into a separate “full” version.
- September: ran a few rounds of internal testing, then released the free version on Google Play.
Rethinking the Release Plan after the MVP
My original plan was a free app with a paid tier. Paying users would get sign-in, sync across devices and a few handy extra features.
I already knew my target audience: expats living in Germany who were preparing for the exam. What I didn’t know was whether they’d find it useful, which features they’d rely on, or whether any of them would pay for sync. So I decided to launch completely free and revisit the premium idea later, with real data.
First attempt: a feature flag
My first move was a single feature flag in the shared Kotlin code: one true/false value that decides whether the paid features exist. Because it was a compile-time constant, the compiler could strip out any code behind it when it was off. With the flag off, the app never started Supabase or RevenueCat, and all sign-in and premium screens disappeared.
From a user’s point of view, this worked. Under the surface it got messy. The Supabase, RevenueCat and Google Sign-In libraries were still packed inside the app, which made the app size bigger. The app still asked for Google Play’s billing permission even though it sold nothing. And the checks were spread across several screens, so each new feature meant another check to remember.
Second attempt: separate modules and two builds
I moved sign-in, sync and payments into their own modules, which are separate parts of the project that can be included in a build or left out. Then I set up two product flavors for building different versions of an app from the same code:
- free: the current version on Google Play. It doesn’t include the sign-in, sync or payment modules at all, so the app is smaller and contains only what it uses.
- full: everything, including sign-in, cloud sync and subscriptions.

High-level architecture of the free and full flavors
What I Learned
- Learn who your users are before you charge them. I built sync and payments before I had a single user. The work isn’t lost, since it lives on in the full build. However, launching free earlier would have taught me more, sooner.
- Hiding a feature and removing it are different things. A feature flag can hide a feature from users, but its code still ships inside the app. To leave code out of a build, you need to separate it at the build level.
- Wrap third-party libraries in your own types. Let only one small part of your app talk to a library directly. That part converts the library’s data into simple types you define, and the rest of the app only uses those. In my app, RevenueCat’s own data type had spread across several screens. Before I could move payments into a separate module, I had to replace it everywhere with my own type that answers one question: does this user have premium? Now that conversion happens in one place inside the payment module, and switching payment providers would mean changing only that one place.
- KMP let me share most of the app, and the platform-specific parts took the most time. Screens, logic, the question data and the local database are all shared. Sign-in, billing setup and crash reporting needed separate code for each platform, and they took longer than I planned.
Tips for Your First KMP App
Working with Android and iOS
- Android Studio covers most of the work. I wrote nearly all the code there, and I could build and run the iOS app from Android Studio too.
- Some things need Xcode. I opened Xcode, Apple’s development tool, to set up signing so the app would run on my own iPhone, and to add iOS-only libraries like Firebase through Swift Package Manager (Apple’s built-in tool for adding libraries). You’ll also need a Mac for any iOS work.
- Build both platforms often. If you mostly build for Android day to day, the iOS build can break without you noticing. It happened to me.
-
Start with everything in shared code. Only write platform-specific code when you have to. KMP handles this with
expectandactual: shared code declares that it expects something to exist, and each platform provides the actual version.
Builds and Dependencies
-
Keep all library versions in one file. Gradle’s version catalog (
libs.versions.toml) lists every library and its version in one place, so every module uses the same versions. - Adopt Gradle convention plugins early if your project has several modules. Convention plugins are small plugins you write yourself to hold the dependencies your modules share, so each module’s build file stays short and consistent. I will write more on this in the project structure article.
- Expect upgrades to come in groups. In KMP, the Kotlin version, Compose Multiplatform and some build tools need to match each other. Upgrading one can require upgrading the others, so I upgrade in small steps and read the release notes first.
-
Keep secrets out of your repository. My API keys, the app signing key and the Firebase config file (
google-services.json) are stored as GitHub Actions secrets (encrypted values that only the automated builds can read). The build pulls them in when it runs, so none of them is ever committed to the code.
Automated Builds (CI)
- Set up CI early. CI (continuous integration) means your project is built and checked automatically every time you push code. I added GitHub Actions in the second week. Every pull request runs the unit tests, measures code coverage and posts the result as a comment on the pull request, runs Android Lint to catch common mistakes, and checks that the free build contains no paid libraries.
-
Use branch names to trigger releases. When I push a branch whose name starts with
release/, CI runs the tests and then builds a signed app bundle, the file you upload to Google Play. A branch starting withbuild-apk/gives me a signed APK instead, which I can install directly on a test phone. To prepare a release, I create a branch likerelease/1.4, push it, and a few minutes later the signed file is ready to download. - Turn important rules into CI checks. My free build must not contain any payment or sync libraries, so CI lists the free build’s dependencies on every change and fails if any of them appears.
Shipping and Testing
- Add crash reporting early. I added Firebase Crashlytics to both platforms in the second month. It was helpful to get more insights during internal and closed testing.
- Test purchases without paying. Google Play lets you add license testers: accounts that can go through the whole purchase flow without being charged.
- Plan for database changes. When you change the structure of your local database, write a migration so users keep their progress after an update. My database has changed three times so far.
- Keep notes as you go. I keep a document in the project explaining why I made each decision. It has saved me a lot of time when I came back to code weeks later.
Closing Thoughts
Thanks for reading this far. :)
This project did what I hoped it would. I learned Kotlin Multiplatform by shipping a real app, and I built something of my own that people can use today. If you’re preparing for the “Leben in Deutschland” test, or know someone who is, I’d be glad if you gave Leben in DE: Einbürgerungstest a try. Next, I plan to dive deeper into the key product decisions behind the app, with code from the project and the trade-offs behind each choice.
Next in the series: Part 2, How I Structured My First KMP App: Modules, Layers and Source Sets.
Top comments (2)
Shipping a first KMP app all the way to Play is no small thing, congrats 🎉
Thanks mate!