Unlost in Translation
An open-source project by Radu Marias: an app that translates, then checks the result against context, tone and culture before you send it. I am a contributor, working alongside Radu — the project and its direction are his, and every change of mine went through a pull request he reviewed and merged. My part has been restructuring the app into a Kotlin Multiplatform project that also runs in a browser, and building the end-to-end encrypted two-device conversation feature.
- Kotlin
- Kotlin Multiplatform
- Compose Multiplatform
- Kotlin/Wasm
- Ktor
- kotlinx.serialization
- Room
- Gemini API
- Cloudflare Workers
- WebSockets
- ECDH · HKDF · AES-GCM
- Robolectric
- Roborazzi
Whose project this is, and what I do on it
Unlost in Translation is Radu Marias' project. He owns it, sets the direction, and reviews and merges everything that lands. I contribute to it, working next to him rather than on my own: we agree on what an arc should do, I build it in the open across a series of pull requests, and he reviews them.
That collaboration is most of what I got out of it. Working with someone senior means the first version of a design is usually not the one that ships, and the reasons why are the part worth learning. What follows is the work I did — not a claim on the project.
How this was actually built
Most of the Kotlin here was written by coding agents, working from what Radu and I had agreed an arc should do. I am not going to turn that into a claim of fluency in Kotlin Multiplatform, because it is not one.
What I did was decide what got built, set the agents up to build it, and then stand between their output and the branch: reading every diff, writing the tests that had to pass before anything merged, running the audit that turned up the security findings below, and keeping the document that grades which parts are proven and which have never run anywhere.
That document exists because of how the code was written, not in spite of it. When you did not type something line by line, the only defensible position is to be explicit about what has been checked and what has not.
Where it started
The app began as an AI Studio export: one Android module, screens and logic in the same files, network calls written by hand against Gemini. It worked, and it could not be extended. The job I took on was giving it an architecture.
The work ran in phases, each one a pull request that had to leave the app working. Nothing was rewritten wholesale.
Taking it apart
First the seams. Screens and components moved out into their own files, the translation service was ported to Ktor with kotlinx.serialization, the view model became a plain class with dependencies handed to it by an application container instead of reaching for Android APIs directly, and history went behind a repository interface.
Then the parsers were pulled out as pure functions and pinned with fixture-backed tests. That mattered later: those parsers are the part that turns an unpredictable model response into typed data, and they are now the best-tested code in the project.
One codebase, three targets
With the seams in place I created a shared Kotlin Multiplatform module and moved the domain model, network layer, platform interfaces and Room storage into it. Then Compose Multiplatform went on top, a wasmJs target was added, and a separate web module started hosting the same composables on a canvas.
Two things had to be solved to get there. The serialization compiler plugin did not cooperate with Compose on wasm, so the serializers are hand-written. And the platform layer needed real web implementations: history in localStorage, voice input through the browser's SpeechRecognition, speech synthesis, an image picker. Features that only exist on a phone — Wear sync, routing audio to two different headsets — are hidden by capability gating rather than crashing.
The security audit
Partway through I audited what was there and wrote the findings down. Four were real:
- The API key travelled in the URL
- and a timeout message printed the failing URL on screen. It moved into a header, and two tests now assert the key never appears in a URL or in an error message.
- Translation history was in the Android backup set
- — everything anyone had ever translated followed the account to a new device. Excluded from backup and transfer.
- The Wear service logged translation text to logcat
- , where any app with log access could read it. Removed.
- An empty response from Google crashed on an array index
- instead of reporting a parse error. It now fails as what it is.
Two phones, one conversation, end to end encrypted
The largest piece of feature work: two people, each holding their own phone, in one live translated conversation. It needed a transport, a protocol and crypto, and none of it could be trusted to a server.
A WebSocket fan-out relay runs on Cloudflare Workers and only ever sees ciphertext. The wire format and the link seam live in shared code, so the same transport drives Android and the browser. Recent frames are replayed on reconnect, because a dropped connection must not silently eat someone's turn. The session key comes from an ECDH exchange through HKDF into AES-GCM, with fixed vectors pinning the key schedule, and the handshake fails closed — if it cannot verify, it does not send. Before the conversation starts, both people compare a short authentication string, so a relay that swapped keys is visible to a human.
Later I made the same thing work from the browser, with the pairing QR carrying an ordinary https link so scanning it opens the app when it is installed and the web build when it is not.
Sixteen languages, one catalogue
A translation app that only speaks English to its user is an odd thing. Every shared string moved into a single catalogue, including the messages the view model produces, and the interface was translated into all sixteen languages the app supports.
The tests check what a human reviewer would miss: every supported language has a catalogue, locale resolution falls back correctly, placeholders survive translation, and nothing is blank.
What is proven, and what I said was not
The repository carries a status document I wrote that grades every part of the codebase by how much evidence stands behind it, and it is deliberately unflattering. The network layer, the view model, Room and the image downscaler are covered by tests that run. Most of the interface is reviewed but not executed. Every remaining platform integration — camera hand-off, text to speech, the watch, most browser APIs — has never run anywhere, and the document says so.
The tests that do exist earn their place. Nineteen run against a real Ktor WebSocket server. Forty-five drive the Gemini request shapes through a mock engine, down to the exact JSON body. Twenty-five cover the remote half of the view model. Seven cover Android image loading, and those seven found three real bugs — including one where the decoder returned null for every single image, which means camera and gallery translation could never have worked at all.