featured image

FreedWise: A Mobile Reader Where Every Highlight Becomes a Flashcard

FreedWise collapses reading and spaced-repetition review into one offline-first mobile app. You highlight, FSRS schedules it. No account, no sync server, no network — the whole system lives in SQLite on your device. The hardest part was building a single WebView reader that handles both EPUB and PDF across 17 iterations of vendor asset versioning.

Published

Tue Sep 15 2026

Technologies Used

React Native Expo TypeScript SQLite FSRS Zustand Mobile
View on GitHub

Live Demo

Loading video...

Most reading apps and spaced-repetition apps are separate products. You highlight in one, export in some format, import into Anki, edit card templates, hope the content survived the round-trip, and then maintain two separate review queues. FreedWise is the bet that those two workflows should be the same workflow. You highlight something worth remembering, and it’s immediately queued for review using the FSRS algorithm. No pipeline, no export, no third service. Your library, highlights, and review schedule all live in an SQLite database on your device.

The constraint I set from the beginning: zero network. No account creation, no sync server, no analytics, no crash reporting. That constraint shaped every decision downstream, from how vendor libraries get deployed to how backups work.

The Stack

LayerTechnologyWhat It’s For
RuntimeReact Native 0.83 + Expo 55iOS and Android from one codebase
LanguageTypeScript 5.9 (strict)Type safety across UI, services, and data layers
StateZustand 5Lightweight global state without Redux boilerplate
Renderingepub.js + PDF.js inside WebViewIn-browser rendering bridged to native highlight events
Schedulingts-fsrs 5.3Free Spaced Repetition Scheduler algorithm
PersistenceExpo SQLiteVersioned migrations, transactional writes
Importexpo-document-picker + magic byte validationReject corrupt files at import, not at read time
Notificationsexpo-notifications (local only)Daily review reminders without a push server

React Native + Expo was the only reasonable choice for a two-platform app where I wanted a single codebase. Expo specifically makes the SQLite, file system, and notification APIs consistent without digging into native modules. Zustand replaced Redux because the app’s state is manageable — the boilerplate-to-benefit tradeoff for Redux didn’t hold here.

The spaced-repetition piece uses ts-fsrs, the TypeScript port of the FSRS algorithm. FSRS outperforms SM-2 (what Anki uses) in recall accuracy on most content types, and the TypeScript port maps cleanly to a relational schema. Every highlight stores FSRS state directly as columns: stability, difficulty, reps, lapses, state, due_date. No separate card table — the highlight is the card.

One detail worth calling out on the import side: file extensions lie. Rather than trusting that a .pdf file is actually a PDF, the importer reads the first four bytes and checks magic numbers before copying anything to the book library.

const MAGIC_BYTES = {
  pdf:  [0x25, 0x50, 0x44, 0x46], // %PDF
  epub: [0x50, 0x4b, 0x03, 0x04], // PK\x03\x04 — EPUB is a ZIP
}

A misconfigured or truncated file throws a validation error at import time rather than silently breaking the reader later.

Seventeen Iterations to Get One Reader Right

The piece I’m most proud of and most relieved is done: a unified WebView reader that handles both EPUB and PDF through the same code path.

Most apps separate these formats. EPUBs need epub.js and CFI-based positioning; PDFs need PDF.js and page-plus-coordinate positioning. Keeping them separate is simpler — two readers, two highlight bridges, two maintenance surfaces. I wanted one.

The approach: a versioned HTML bundle that lives on the device’s file system (not inside the app binary) loads both epub.js and PDF.js at runtime, exposes a shared JavaScript API that React Native calls via postMessage, and handles all highlight rendering inside the WebView. The React Native side sends commands and receives events. It never touches the DOM directly.

What I underestimated was how many production problems would surface before that was stable. The VendorAssetManager currently ships at LIB_VERSION = '17'. Each version number represents a real failure mode:

v3 — Android WebView rejected the vendored epub.js with MIME type errors because file:// URLs don’t get content-type headers. Fixed by loading all scripts synchronously from a local index.html rather than dynamic <script> injection.

v6 — EPUB CFI positions drifted when users switched between scroll and paginated mode mid-session. Stored a per-book flow mode flag; invalidate and recalculate CFIs on mode switch rather than trying to preserve them across incompatible layout models.

v10 — Tap-to-turn-page overlays built as fixed-position divs inside the WebView stopped registering correctly when Android zoomed the viewport. WebView pan moves the entire viewport, so a tap on a “fixed” overlay hit the wrong position. Replaced with native Pressable zones layered over the WebView in React Native — they stay in screen space regardless of zoom state.

v14 — Highlight rehydration after bookmark restoration was skipping the first few highlights sporadically. Race condition between the WebView signaling readiness and the host sending rehydration messages. Fixed with a handshake: the WebView signals ready, the host waits for that event before sending any rehydrate commands.

v17 — Current version. Stable on both platforms, handles the edge cases I know about.

The versioning solves a real deployment problem. The vendor assets (epub.js, PDF.js, jszip, and the reader runtime) are extracted from the app bundle to the device’s documents directory on first launch, then reused across sessions. That extraction step is expensive, so you only want to do it when something actually changed. The version gate makes reinstall automatic when users update the app, without requiring them to know anything changed.

The highlight system itself: when you highlight text, the WebView sends a message with the selection’s CFI (EPUBs) or page number and normalized bounding rectangle (PDFs), plus the selected text. That data gets persisted to SQLite immediately. When you reopen the book, a rehydrate command sends all existing highlights back into the WebView, which re-renders them via its annotation layer. Highlights survive app restarts without touching the source file.

The Offline Guarantee Is Architectural, Not Configurational

Zero network isn’t a setting that could be turned off — it’s enforced at multiple levels. The WebView’s Content Security Policy forbids all external origins: default-src 'none'; script-src 'self' 'unsafe-eval'.... There’s no fetch() or network import anywhere in the service layer. epub.js and PDF.js are vendored and served from the file system. Review reminders use expo-notifications with local scheduling — no push server, no token registration.

The service layer is structured as a dependency-injected set of modules — BookService, HighlightService, FSRSService, SearchService, NotificationService, DataService — all registered through a ServiceFactory at startup. In tests, that factory swaps in mock implementations. In production, every service talks exclusively to SQLite and the file system. Nothing escapes the device.

This creates a specific relationship with users: uninstalling means their data is gone unless they’ve run a backup. That’s a deliberate tradeoff. Building sync infrastructure, authentication, and a backend would take months and introduce ongoing operational dependencies. For a reading tool, offline-first also means it works on planes, without cell service, and without worrying about what happens when the backend goes down.

Action Tags: Annotating While You Read

One small design decision that turned out more useful than I expected: embedded action tags in highlight text.

If you append .q "What mechanism explains this?" to a highlight, it becomes a flashcard with a question side shown first during review. .discard marks a highlight as excluded from review (useful for passages you highlighted for reference but don’t want to drill). .h1, .h2 mark structural headers for outline views.

The alternative was a button-heavy annotation UI — a modal or popover for each highlight with options for flashcard creation, discarding, and tagging. That interrupts reading flow. The tag approach means you can annotate at the speed of typing, inline, while you’re still in reading mode. Power users use it; casual users ignore it and highlight normally.

What I’d Redo

The EPUB flow mode handling is the roughest part of the codebase. When a user switches between paginated and scroll mode, there’s a CFI invalidation step that works but is inelegant — it stores a per-book mode flag and uses it to decide whether to attempt CFI restoration or fall back to percentage position. I’d redesign the position abstraction to be mode-aware from the start, so the reader can translate between positioning systems rather than choosing one and discarding the other.

The fallback scheduling for FSRS calculation failures is the right call — a crash in the scheduling algorithm shouldn’t block reviews — but the current fallback (1d/3d/7d/14d fixed intervals) loses the personalization that makes FSRS valuable. A smarter fallback would interpolate from the card’s last known stability rather than ignoring it entirely.

Try It Out

Check out the source code on GitHub.

Respecting your privacy.

← View All Projects

Related Tutorials

    Ask me anything!