I've always found reading hard. Long documents slide off my attention, and I lose my place constantly. What I really wanted was something that would read to me and show me the words as it went — so my eyes and ears stayed in sync. Nothing did exactly that, so I built it.
It's called ReadAloud, it's on Google Play, and this post is the "why" and the interesting bits of the "how."
The moment it became real
The first person I showed a rough build to was my Sister, Praise. She'd come to town to officiate a Women's Premier League match at Auntie Aku Astro Turf Park, and I pulled out my phone between everything else. She watched a paragraph read itself aloud with each word lighting up and got genuinely excited — that was the push I needed. She became tester #1. My colleague Reggie became tester #2. Between them they found the rough edges I'd stopped seeing, and the app settled into something stable.
What it is
A text-to-speech reader for PDFs, EPUB, DOCX, plain text and web articles. It reads aloud in natural voices, highlights each word as it speaks, and auto-scrolls to follow along. There's offline listening, English/French/Spanish, speed-reading (RSVP), a vocabulary builder, and reading stats.
The stack: Kotlin, Jetpack Compose + Material 3, MVVM + Clean Architecture, Hilt, Room, DataStore, WorkManager, minSdk 26. Now the parts that were actually interesting to build.
1. Word-by-word highlighting
This is the whole product, so it had to be right. On-device voices are easy — Android's TextToSpeech gives you onRangeStart (API 26+), which fires per spoken range:
override fun onRangeStart(utteranceId: String, start: Int, end: Int, frame: Int) {
// highlight the substring [start, end) in the reader
_currentRange.value = start to end
}
The catch: the natural cloud voices people actually want don't emit onRangeStart. So for cloud synthesis I wrap each word in an SSML <mark> and ask Google Cloud TTS to return timepoints:
<speak><mark name="w0"/>Every <mark name="w1"/>word <mark name="w2"/>counts.</speak>
Each timepoint maps a mark → a timestamp; I pair that back to the word's character range and drive the highlight from MediaPlayer.currentPosition. Some families (Studio, Chirp) reject SSML outright, so those fall back to a syllable-weighted time estimate. Two code paths, one visible behaviour — I call it "dual highlighting."
The lesson learned the hard way: highlighting regressed silently more than once as I refactored the reader. It never crashed — it just quietly stopped tracking. Now any change to the reading pipeline runs a highlight check before it ships.
2. Keeping API keys off the device
Cloud TTS costs money per character, so the API key is a liability if it ships in the APK (anyone can unzip a .aab and grep for AIza…). My rule: no third-party keys in the binary.
Instead the app fetches config from a small server. Every request is HMAC-signed, and every response is encrypted:
fun sign(method: String, path: String, ts: Long, deviceId: String, body: String): String {
val message = "$method|$path|$ts|$deviceId|$body"
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(APP_SECRET.toByteArray(), "HmacSHA256"))
return mac.doFinal(message.toByteArray()).joinToString("") { "%02x".format(it) }
}
The server verifies the signature, then returns the keys as an AES-256-GCM blob whose key is HKDF(masterKey, salt = deviceId) — so every device gets a distinct derived key. The app decrypts, caches, and uses them. Rotating a key, blocking a device, or flipping maintenance mode is a server change, not an app release.
A debugging war story from this exact system: a debug build once compiled without the shared secret and silently baked in a placeholder. Every signed request came back 401, cloud voices vanished, and it looked like a server outage. It wasn't — the fix was a rebuild. The takeaway I now live by: fail loudly when a required secret is missing, especially in debug.
3. PDFs that don't scramble
PDF text extraction is famously messy. Two-column layouts interleave line-by-line, and running heads/footers leak into the middle of sentences. I run a column-aware reordering pass (cluster text runs by x-position, read each column top-to-bottom) and strip repeated page furniture.
The subtle part is resume + highlights. If I anchored them to page/character offsets, they'd break the moment extraction changed. Instead they're content-anchored — tied to the surrounding text — so your place and your highlights survive a re-extraction or a settings change that reflows the page.
4. Offline listening
A commute shouldn't need signal. A WorkManager job synthesizes the whole book once in your chosen voice, storing the MP3 chunks plus a sidecar of word timings:
data class OfflineChunkAudio(val bytes: ByteArray, val timings: List<WordTiming>?)
Playback then runs fully offline, with the same word highlighting as the live path, because the timings travel with the audio.
What I'd tell my past self
- The core feature is the product. Highlighting is 5% of the code and 95% of the value — treat it like a first-class citizen, with tests.
- Accessibility helps everyone. Bionic reading, adjustable spacing, colour overlays, a sleep timer — I added them for myself and they made the app better for everyone.
- Ship the boring safety. Server-held keys, "fail loud" on missing secrets, content-anchored state. Each one saved me later.
- Real testers beat assumptions. Praise and Reggie caught things I was blind to.
Try it
ReadAloud is on Google Play: https://play.google.com/store/apps/details?id=com.bless.readaloud.app
It's free; premium unlocks the most natural voices and offline audio. If reading is ever a chore for you the way it is for me, I hope it helps — and I'd love your feedback.
Built with Kotlin + Compose. Happy to go deeper on any of the above in the comments.
Top comments (0)