How PandaReader works
PandaReader is a single Android app written in Kotlin: about 25,000 lines across roughly 110 files, with Jetpack Compose and Material 3 for the shelf and settings, and classic Android views for the reading surfaces where pixel-level control matters. This page walks through each layer and, more importantly, why it is built the way it is.
The big picture
Data flows top to bottom: sources are scanned into a repository, the ViewModel shapes the shelf, and opening a book fetches it to a local file that one of three reader engines consumes.
The source layer
Every server type implements the same small interface: list a directory, read a file, and report whether a Calibre library is present. WebDAV, FTP, SFTP and SMB use well-maintained JVM libraries. Two were written from scratch because nothing suitable existed:
NFSv3 without a library
No maintained Android NFS client exists, so the app carries its own ONC-RPC client, XDR encoder and NFSv3 session. A session is one live pair of RPC connections, one to the MOUNT service and one to NFS, both discovered through the portmapper rather than assumed, plus the root file handle. File handles are never cached across sessions, since NFSv3 does not guarantee they persist; every access resolves the path again, which is exactly how the other sources already behave.
Calibre's wireless protocol
PandaReader is a "client" only in the TCP sense. Calibre speaks first after the connection is accepted, so the implementation blocks on a read as its very first action and never sends anything unsolicited. Opcodes and framing follow Calibre's own smart-device driver, and the sync runs as a service so a long transfer survives the screen locking.
SFTP keys and Bouncy Castle
SSH key auth imports OpenSSH private keys, stripping byte-order marks and tolerating odd line endings, or generates an ECDSA P-256 pair on the device. Early SFTP connections failed on key exchange because Android's built-in Bouncy Castle provider is cut down; the app registers the full provider at startup, pinned to the exact version sshj and smbj request, because bumping it independently broke key exchange in a different way.
Paths that ignore case
Every path a user types is resolved segment by segment against the real directory listing, so a wrong-case folder name still works on case-sensitive servers.
Scanning, grouping and covers
Calibre first, filenames second
When a source contains Calibre's metadata.db, the catalogue is read directly: titles, authors, series membership and index, tags, publication date and date added. Series folders are therefore real database membership rather than a title-pattern guess. Sources without Calibre fall back to filenames.
One entry per book
Copies of the same book from different servers and in different formats are grouped by normalised title and author. Reading progress is tracked per exact copy, so the group's status is "in progress" if any copy is, and the pin-to-top ordering uses the most recent open across all copies. This fixed a real bug where six of nine in-progress books were hidden because a stable sort kept picking an untouched copy.
Covers without downloading the book
Cover extraction streams the EPUB's zip directory, follows container.xml to the OPF to the cover entry, and stops as soon as it has the cover bytes. A multi-hundred-megabyte comic EPUB yields its cover in a few hundred kilobytes of transfer. Decoded thumbnails live in a memory cache sized at an eighth of the heap and capped at 64 MB, in front of a JPEG disk cache, so a shelf cell that scrolls back into view never touches the decoder again.
Why the scanner lists formats it cannot open
The book detail page is meant to show what the library holds, so Calibre's other output formats are recognised and listed even though only fifteen are readable. Formats that are DRM-only in practice, such as KFX, are excluded from scanning entirely so they never appear as an available format only to fail when tapped.
The text engine
The first reader was a WebView: every chapter concatenated into one HTML document for scroll mode, CSS multi-column pagination for paged mode. It worked, and it taught two hard lessons. Chromium silently renders blank past a certain width in a multi-column element, so paged mode had to load one chapter at a time; and applying a transform, overflow clipping and column layout to the same element renders blank for any non-zero page. The deeper problem was that a WebView loading content incrementally made backward scrolling jump, and there was no way to fix that from the outside.
Version 1.2.0 replaced it with an engine built on Android's own StaticLayout. Chapters are parsed from XHTML with Jsoup, with images resolved relative to each chapter file's own location the way a browser would, then laid out into text layouts that are either sliced into viewport-sized pages (paged mode) or stacked into one continuous column (scroll mode). Chapters are built concurrently on every core, and embedded images are decoded pre-downsampled to their on-screen size rather than at full resolution.
Exact page counts, never estimates
Page totals come from the same layout you are reading, so they are exact. An earlier bytes-per-page estimate was rejected outright because the total kept changing while reading, and estimation has not come back.
Opening instantly (version 1.7.0)
Building every chapter before showing anything meant a long "Preparing book" screen on big books. The engine now estimates each chapter's height from its character count and image sizes, lays out only the chapter you are resuming, shows the page, and then builds the rest in the background in an order that fans outward from where you are. As each chapter finishes, the estimate is swapped for the real layout, the scroll position is adjusted by the height delta so the text under your finger does not move, and the page count settles within a couple of seconds.
Position, selection and highlights
Position is stored as chapter plus character offset alongside the percentage, so a font-size change still resumes on the same words. Selection is handled by a controller that maps screen coordinates into each chapter's layout, drives the two handles, and shows Android's platform magnifier at the touch point while a handle is dragged. Highlights are persisted per book with their notes, drawn as a translucent yellow that reads on all three themes, merged automatically when two highlights meet across a page break, and editable after the fact.
The PDF engine
Android's built-in PDF renderer only produces bitmaps. PandaReader uses pdfium through a native binding instead, which exposes link annotations, per-character text geometry and the document outline. That is what makes clickable links, real text selection with highlights and notes, and a chapters drawer built from the PDF's bookmarks possible. Link bounds arrive in the page's point space and are converted against the render scale before hit-testing. Dark theme can optionally invert page colours. Pinch-to-zoom and free panning share the same controller as the bitmap pager.
The bitmap pager
Comics and scans are fixed raster pages, not something to paginate. A PageSource interface gives each format one job: report a page count and render page N no larger than the viewport. CBZ reads the zip index, CBR uses a pure-Java RAR 4/5 reader, CB7 uses commons-compress, and CBT extracts once into a per-book cache because tar has no index. Filenames sort naturally so "page2" comes before "page10".
DjVu has no JVM decoder, and the app has no native build step, so djvu.js runs inside a hidden WebView attached to the real view hierarchy and is driven through a JavaScript bridge. A separate call returns every page's dimensions from the already-parsed document directory, because rendering each page just to learn its size took minutes on a 360-page book.
Zoom and pan were a regression from the WebView era and came back in version 1.5.0 as a controller shared by comics, DjVu, PDF and image-heavy EPUB pages: zoom in, move freely, and the view never snaps back.
Sync and offline mode
Mirroring
Sync runs as a foreground service with a notification, so it continues when the app is minimised or the phone locks, and a module-level mutex stops overlapping runs. It only adds files that are missing locally; it does not delete local copies of removed books and does not re-download, which matches "sync new files down automatically". Files are named by the book's real title so the folder is browsable from any file manager. Progress reports phases and per-file throughput to the diagnostics log. A byte-level live progress display was tried and removed because the rapid flicker was a seizure risk.
Deciding what is available offline
The ViewModel keeps the full library internally and publishes a derived view. When the device is offline and "hide unavailable content" is on, the published book list is swapped for the subset that is synced or previously downloaded, so the shelf, folders, counters, detail formats and author sheet all shrink through the existing code paths. The availability pass had to be made fast: listing a 1,500-file sync folder one query per file took eleven seconds and never finished before the user looked, so it became a single content-resolver query. Airplane-mode and connectivity broadcasts re-run the check live, with a resume-time safety net.
No-network launch
Early versions felt frozen when launched with no network because every source was probed on the main path. Network checks are now bounded, reassociation after unlock is waited for instead of failing fast, and a synced server keeps showing its books from the local mirror when its server is unreachable.
Series lookup and rate limits
When Calibre has no series for a book, three public sources are tried in the order you set: Hardcover's GraphQL API with your own personal access token, Open Library's search API, and Google Books' volumes API, which has no series field but often carries "(Series, #N)" in the title. Each has a per-device cache that remembers hits forever and remembers a clean "no series" answer forever too.
Failures are deliberately never cached. Version 1.7.1 fixed a real bug where a Hardcover key missing the read:catalog scope produced a 403 that was written to the cache as "no series" for every book it touched. Now a 401, a 403 with a missing scope, a 429 or a network failure leaves the book unresolved for the next run. Hardcover's Retry-After and RateLimit headers pause every Hardcover call app-wide until the window resets, and lookups are spaced at least 1.1 seconds apart. Settings' "Save & test" understands scoped keys: a key that can search but cannot read the profile reports connected, a missing scope is named, and throttling shows how long to wait.
Storage, security and diagnostics
- Secrets live in EncryptedSharedPreferences backed by the Android Keystore: server passwords, SSH private keys and API keys.
- Per-feature stores keep reading progress, last-opened times, highlights and notes, PDF highlights, page notes, tags, favourites, hidden items, collections and chapter page counts as small JSON or preference files, each owned by one class.
- Diagnostics capture scan, sync, network and reader-timing events to an in-app viewer and to
files/logs/app.log, which is readable over adb. Text-book opens log the time per phase: reading, parsing and layout. - What's new is a static list in the code that predates the git history and remains the source of truth for every release. This website's changelog is generated from it.
How it ships
Every user-visible change gets a changelog entry and a version bump together. The private source repository is tagged per version, and two builds are produced: a debug-signed APK for the developer's own devices, so logs can be read over adb, and a release-signed APK published to the public PandaReader-releases repository. The release key lives outside the source tree. The first public release was 1.7.0 in September 2026; the in-app changelog goes back to the project's start in July 2026.
Fixes are verified against real data before they ship: reading the device's own preference files to see why pinning missed books, pulling real MOBI files to find the trailing-bytes crash, and checking the app log for layout timings after the progressive engine landed.