Skip to content
All products
Live · one-time Pro

RookReader

An Android reader for ebooks, audiobooks and web serials, with cross-device sync, a vocabulary trainer, and an AI companion that is structurally incapable of spoiling the book for you.

One-time
Pro unlock, not a subscription
7
Data types on one sync store
0
Ads, in either tier
RookReader's library screen on Android: a continue-reading card, smart shelves, and a cover grid, with Library, Novels, Listen and Profile tabs.

Status

Live · one-time Pro

Platforms

Android (Flutter) · iOS in progress

Role

Sole designer, engineer, and operator

Stack

FlutterDartRiverpodgo_routerDriftSupabasePlay BillingSentryPostHog
01At a glance
01

Reflowable and fixed-layout rendering behind one ReaderEngine seam

02

AI reading companion that cannot see past the page you are on

03

Seven synced data types on a single generic object store, with no per-type tables

04

Refund revocation with both a push and a pull path, after the push path failed silently

05

Vocabulary deck with an SM-2 review scheduler, Word Wise hints, and Anki export

06

Audiobooks, TTS read-along, and a web-novel module that is feed-only by legal decision

The problem

Every major Android ebook reader fails in at least one of the same four ways. Sync is broken or paywalled, or in KOReader's case works but expects you to self-host a server. Monetisation is hostile: interstitial ads between books, or a subscription for typography. Power-user depth and a clean UI are treated as a trade-off nobody has resolved. And serialised web fiction has no good home at all.

RookReader is a bet that those are product decisions rather than hard constraints: sync that works across unlimited devices, no ads in either tier, one-time pricing, and depth reached through progressive disclosure instead of a cluttered UI.

Architecture

Flutter and Dart, feature-first, with Riverpod for state and go_router for navigation. Local storage is Drift; the backend is Supabase. Each feature splits into domain (plain models, no Flutter or IO), data (repositories and engines, the only layer permitted to touch storage, SDKs, or platform channels), and presentation.

Formats are the reason that boundary is strict. A single ReaderEngine seam turns EPUB, fixed-layout, and comic formats into one consistent reading surface, so the rest of the app never learns which format it is displaying. Adding a format means implementing an engine, not threading a new case through the reader.

An AI companion that cannot see ahead

The companion answers questions about the book you are reading: a recap of the story so far, ask-the-book Q&A, a character refresher. The obvious implementation sends the book to a model and asks. That implementation spoils the ending.

So the security boundary is a pure, unit-tested function that assembles the reading window: book text from the start up to the reader's current locator (chapter index and scroll fraction), never a word beyond it, then trimmed to a character budget keeping the most recent text. The prompt builders restate the no-spoiler rule on top of that, but the guarantee lives in the window, not in the prompt. A model asked politely not to spoil a book will eventually spoil the book; a model that was never given the next chapter cannot.

The model is bring-your-own-key behind an AiClient seam, so the text goes to the reader's chosen provider and never to a RookReader server. Model choice is fetched live from the provider's own catalogue rather than hardcoded, because a hardcoded model id is a dead feature the day it is deprecated.

Sync as one generic store

Adding a synced data type is a codec pair and one reconcile call. Rather than a table per type, there is a single per-user document store keyed by kind and object id, with a pure merge planner doing whole-object last-write-wins on a timestamp, plus tombstones.

Seven kinds ride it: books, audiobooks, reading sessions, vocabulary words, novels, collections, glossary entries. Not one of them needed its own table, its own row-level-security policy, or its own merge logic. A dedicated table is reserved for the case that really needs field-level merge.

The traps are the interesting part. A repository whose delete writes a tombstone is also what the reconcile pass calls to delete locally, so the pass has to clear the tombstone it just created in the same run. Otherwise the device re-pushes a delete it has already applied, forever.

The refund that never revoked

Pro revocation after a refund used to depend entirely on real-time developer notifications: Google, to Pub/Sub, to a webhook. Every hop in that chain lives outside the repository, so if any one of them is misconfigured the chain fails silently and permanently.

It did. Three refunded test purchases still read as active weeks later, their updated-at timestamps never once bumped, and the logs showed zero webhook invocations. Nothing had errored. Nothing had alerted. The push path had simply never run.

The fix was to stop treating a push channel as a source of truth. A scheduled job now asks Play directly which purchases have been voided since a watermark and revokes them, with the notification path kept as the fast path. A broken webhook becomes a delay measured in hours instead of a permanent revenue hole. The watermark only advances after the revokes land, the revokes are idempotent, and every run logs its outcome, including when it revoked nothing. For a job whose healthy result is 'nothing to do', silence and death look identical, so it has to say which one it is.

Decisions that were not technical

Web novels are read only through RSS and Atom feeds, never by scraping. The HTML scrapers exist in the tree, with passing parser tests, and are left unregistered on purpose. A legal review of the terms-of-service and copyright grey area decided that, not a benchmark.

The app also holds a hard line on Google OAuth scopes: never anything beyond the per-file Drive scope and basic profile. The verification tier is set by an app's most privileged scope, so a single restricted scope would drag the whole app into OAuth verification plus a paid annual security assessment, showing an 'unverified app' interstitial and capping the user base at 100 in the meantime. Drive import uses the Picker specifically to stay under that line.

Neither decision is visible in the product. Both would have been expensive to reverse later, and both are the kind of call that gets made once, early, by someone reading the terms rather than the docs.

Next project

Timeloop

Habits & reminders