A production Android app, shipped on Google Play, that teaches US healthcare workers Medical Spanish through branching patient-dialogue scenarios — fully offline, with an encrypted local database, on-device speech recognition, and Google Play Billing.
📱 Live on Play Store: com.medlingo.app
This repository is the public portfolio build. See
SHOWCASE.mdfor the exact differences between this repo and the production app. The short version: the engineering is unchanged; the curated content (scenarios, phrases) is reduced to a 4-scenario demo subset that still exercises the entire codebase end-to-end.
20 million US healthcare workers serve a population that includes ~29 million Spanish-speaking patients with limited English proficiency. Phone interpreter services take 3–8 minutes to connect; nurses, EMTs, and pharmacists have 10. The result is patient encounters where the first two minutes — the ones that build trust and surface chief complaints — happen in broken English or not at all.
MedLingo is a learning tool for that gap. Not a translator (a single misinterpreted word once cost a hospital $71M in a malpractice settlement, so the app is deliberately positioned away from clinical use), but a practice environment where a nurse can rehearse her actual intake questions on the bus to her shift.
The full product breakdown lives in docs/USER_PERSONAS.md and docs/APP_DESIGN.md. They are worth a skim — they show the engineering was built around five concrete users, not around abstract features.
| Feature | Description |
|---|---|
| Branching patient dialogues | Scripted multi-node conversations with choice quality (ideal/acceptable) and scoring on path coverage + time |
| Spaced repetition | Leitner 5-box flashcard system with automatic review scheduling |
| On-device pronunciation feedback | SpeechRecognizer (es_MX) + Levenshtein word matcher with Spanish normalisation (silent-H, accent folding) |
| Text-to-speech | TextToSpeech (Mexican Spanish) on phrases, flashcards, and chat messages |
| Vocabulary quiz | Multiple-choice quizzes generated from learned words |
| Phrase toolkit | Role-filtered quick-reference phrases with safety-critical flagging |
| Daily goals & streaks | Streak tracking with grace period and 1-per-week freeze |
| Achievements | 8 milestones with celebration animations (Konfetti) |
| Home widget | Glance-based 2×2 widget showing streak and due reviews |
| Adaptive difficulty | Level-up suggestion card after sustained performance |
| Google Play Billing | One-time premium unlock with restore-on-launch (handles reinstall / device switch) |
| 100% offline | No network dependencies. network_security_config.xml blocks cleartext. |
- Language: Kotlin (JVM 17)
- UI: Jetpack Compose, Material 3, light theme only
- Architecture: MVVM, package-by-feature
- DI: Hilt
- Persistence: Room with SQLCipher encryption (passphrase wrapped by Android Keystore, AES/GCM)
- Async: Coroutines + Flow (StateFlow / SharedFlow)
- Billing: Google Play Billing Library 7.x
- Background work: WorkManager (periodic reminders)
- Widget: Glance
- Speech: Android
SpeechRecognizer+ custom Levenshtein matcher - Min SDK: 26 (Android 8.0) — Target SDK: 35 (Android 15)
- Build: Gradle 8 + version catalog (
gradle/libs.versions.toml), R8 full mode + resource shrinking on release - CI: GitHub Actions (
.github/workflows/ci.yml) — runs tests, builds debug APK, builds release AAB, uploads artifacts
These are the calls a reviewer is most likely to ask about. Files referenced are real and clickable on GitHub.
DatabaseKeyManager.kt generates a 32-byte random passphrase on first launch, encrypts it with an AES-256 GCM key bound to Android Keystore (AndroidKeyStore), and stores the wrapped bytes in SharedPreferences. The plaintext passphrase never persists; the key never leaves the Keystore. This pattern survives backup-extraction attacks because Android Keystore keys are non-extractable.
BillingManager.kt connects, queries premium_unlock, handles purchase flow, acknowledges purchases, and exposes productDetails / purchaseSuccess / purchaseError flows. restorePurchases runs on app start (handles reinstall / device switch) and uses a one-way guarantee: once premium is set true, a delayed sync that returns no purchases will not overwrite it. The defaults are isPremium = false everywhere, never true — a deliberate audit choice after an early build accidentally unlocked content for free.
WordMatcher.kt and SpeechManager.kt wrap the platform SpeechRecognizer (es_MX locale) and grade output via Levenshtein distance after normalising silent-H (hola → ola), accent stripping (así → asi), and lowercase folding. The recogniser is Activity-scoped (one of the documented gotchas of SpeechRecognizer) rather than singleton, to avoid lifecycle leaks.
The schemas live under app/schemas/ and migrations are committed (v6 → v7 added indices without data loss). fallbackToDestructiveMigration is guarded behind BuildConfig.DEBUG so release builds never wipe a user's vocabulary history.
Each Compose screen has its own ViewModel (ChatViewModel.kt, HomeViewModel.kt, etc.) exposed via StateFlow. The home screen's level-up suggestion logic, scenario recommendation scoring, and streak grace-period calculation all live in their respective ViewModels rather than leaking into composables. The repository layer is plain Kotlin objects — testable without Robolectric.
Every meaningful icon has a contentDescription. Touch targets are ≥ 48dp. Text uses sp for system scaling. Three Espresso a11y tests in app/src/androidTest/ run accessibility scanner checks against the home, phrases, and settings screens.
app/src/main/java/com/medlingo/app/
├── data/
│ ├── local/ # Room DB, DAOs, encryption, DataStore preferences
│ └── repository/ # Scenarios, scripts, phrases, progress
├── domain/
│ ├── model/ # Domain types (@Immutable for Compose stability)
│ ├── BillingManager.kt
│ ├── SpeechManager.kt
│ ├── TtsManager.kt
│ ├── WordMatcher.kt
│ ├── DailyChallengeManager.kt
│ ├── StreakFreezeManager.kt
│ └── ReviewPromptManager.kt
├── di/ # Hilt modules
├── ui/
│ ├── chat/ # Scenario dialogue screen
│ ├── home/ # Home + recommendation cards + daily goal
│ ├── onboarding/ # 4-step flow
│ ├── disclaimer/ # Required-on-launch legal disclaimer
│ ├── paywall/ # Google Play Billing UI
│ ├── phrases/ # Role-filtered phrase toolkit
│ ├── progress/ # Stats, streaks, achievements
│ ├── quiz/ # Multiple-choice vocab quiz
│ ├── review/ # Leitner flashcard review
│ ├── settings/ # User preferences
│ ├── components/ # Reusable composables (Konfetti, mic UI, etc.)
│ ├── theme/ # Material 3 + brand green
│ └── widget/ # Glance home-screen widget
├── notification/ # WorkManager workers + NotificationHelper
└── widget/ # Widget receiver
docs/
├── APP_DESIGN.md # Product IA, screen mockups, AI prompt strategy
├── USER_PERSONAS.md # Five healthcare-worker personas the product is built for
├── play-store-listing.md # Play Store listing copy
├── privacy-policy.html # Privacy policy (offline app, no data collection)
└── screenshots/ # 8 Play Store screenshots
./gradlew assembleDebug # Debug APK
./gradlew assembleRelease # Signed release AAB (requires keystore + local.properties)
./gradlew test # Unit tests
./gradlew connectedAndroidTest # Instrumented tests (incl. a11y)
maestro test .maestro/ # E2E flows./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apkThe release build expects a keystore at medlingo-release.keystore and credentials in local.properties (MEDLINGO_STORE_PASSWORD, MEDLINGO_KEY_PASSWORD). Both are gitignored and not shipped in this showcase.
- 54 unit tests — ViewModels (Chat, Home, Progress, Review, Quiz), repositories,
WordMatcher(11 tests covering Levenshtein + Spanish normalisation),DailyChallengeManager - 3 instrumented tests — Espresso accessibility scanner against home, phrases, settings
- 13 Maestro E2E flows — onboarding, navigation, chat, scoring, settings, persistence, empty states
- CI — every push and PR to
main: unit tests + debug APK + release AAB
- SQLCipher database encryption with Android Keystore-wrapped passphrase
- No network traffic (cleartext blocked at the manifest level)
- No patient data ever leaves the device
android:allowBackup="false", sensitive data excluded from extraction rules- ProGuard / R8 full mode + resource shrinking on release
- Mandatory disclaimer on every app start
- No third-party analytics, advertising, or tracking SDKs
Full policy: docs/privacy-policy.html.
MedLingo is a language learning tool, not a medical interpreter, translation service, or clinical decision support tool. Real patient encounters always require qualified medical interpreters.
Nico Schöneburg — independent software engineer.
Released under the PolyForm Noncommercial License 1.0.0.
This is a source-available license, not an open-source license in the OSI sense. In plain English:
- ✅ You may read, clone, run locally, and learn from this code — including in interviews, code reviews, and personal study.
- ✅ Charitable organizations, educational institutions, public research / health / safety organizations, and government institutions may use the code for their non-commercial purposes.
- ❌ You may not use this code, in whole or in part, for any commercial purpose — including shipping a derivative product, integrating it into a paid service, or using it inside a for-profit company's workflow.
The full text is in LICENSE. The canonical version lives at https://polyformproject.org/licenses/noncommercial/1.0.0.
For commercial licensing of this code or the underlying product (MedLingo on Google Play), contact license@nico-ai.de.







