Status: Scoped prototype · 2026-10-29. Owner approved testing a ready-made EPUB parsing/rendering core before implementing local import around the current renderer. This is not approval to replace the production reader or to accept the V2 schema. Implementation is a separate Code Agent task below.
Decision record: ADR-029 — foliate-js renders V2 EPUBs (Proposed, 2026-10-30).
Determine whether a library-owned EPUB parser and renderer can replace enough custom infrastructure to simplify TextStack while preserving translation, annotations, resume and chapter context for MCP. Start with foliate-js as the candidate, not a commitment. No wholesale application fork.
- An isolated development harness, outside production reader routes. One EPUB at a time, no account.
- Open a local EPUB through the candidate's parser and renderer, not by extracting HTML into our existing renderer. Resolve book CSS, fonts, images and chapter links locally.
- Validate the same core in a desktop browser and the existing Android WebView environment. Browser setup may come first for fast feedback; browser success alone does not satisfy the Android gate.
- Use existing translation/annotation concepts through a small adapter. Identify reusable code and assumptions tied to the old DOM, scroll positions or chapter representation.
- Use app-managed local bytes and prototype-only storage. No production progress or library writes.
Not included: production import UX, duplicate/replacement implementation, catalog conversion, sync, MCP authorization/sharing persistence, PDF changes, Dapper work, database migrations or Tauri adoption. Do not implement a second EPUB parser or a general multi-engine framework for this experiment.
| Behavior | Evidence required |
|---|---|
| Local reading | Open retained EPUB bytes with the API unavailable; navigate chapters and internal links; load embedded images/CSS/fonts where present. |
| Word interaction | Tap a word and obtain the correct text and coordinates on both clients. Use an actual translation response when available; a recorded translation may isolate layout tests but must be labeled. |
| Inline translation | Show the translation above the selected word using the intended product interaction. Check scrolling, font-size changes and chapter navigation; a detached tooltip alone does not prove compatibility. |
| Highlight | Select a multiword range, save quoted text plus a candidate locator, reopen and display the saved highlight. Report unsupported cases. |
| Resume | Save a candidate location, close/reopen, and restore it after a font-size or viewport change. A raw scroll offset alone is insufficient evidence. |
| MCP context boundary | Extract the chosen chapter's readable text and selected notes with source provenance. Inspect the payload locally; no live MCP/server integration is needed. Do not include later chapters implicitly. |
Use at least two non-sensitive, redistributable EPUB fixtures: one ordinary reflow book and one with images/styles and internal links. Record fixture provenance. Do not copy a user's private book into the repo. Fixed-layout, DRM and legacy-location migration are not claimed supported by this experiment.
Before coding, record the exact dependency source/revision and verify license obligations for the selected package and any borrowed source. Pin the candidate; distinguish upstream code from patches. Do not pull an entire application fork to get its engine. If available APIs require extensive patches, document that cost instead of hiding it inside the adapter.
Add focused integration checks for adapter outputs (word/range mapping, serialized location and chapter-context boundaries). Run the relevant existing reader checks for any reused code changed. Use real browser/WebView checks for selection, layout and resource access; mocked DOM tests alone cannot establish these behaviors. No coverage target or exhaustive engine test suite for the spike.
Report a pass/partial/fail/not-run matrix by behavior, fixture and client, with exact reproduction steps. Note startup time and obvious memory problems as observations, not production benchmarks. If Android/device access is unavailable, report that gate as not run and leave the conclusion provisional.
Recommend one outcome:
- Proceed to a production local-EPUB PDD using the candidate core.
- Retain the current renderer and evaluate only a library-based format loader.
- Reject this candidate, with concrete blocking evidence.
Assess reduction in owned responsibilities, not only lines of adapter code. Account for locations, DOM hooks, resources, security, dependency patches and the future migration of existing annotations. The prototype need not solve legacy migration; it must expose its incompatibilities.
- Runnable isolated harness and setup instructions; production reader and PDF routes remain unchanged.
- Exact dependency and fixture provenance documented.
- The six behaviors above have reproducible evidence or explicit failures/not-run labels.
- No production user data, server schema or existing saved positions changed.
- Written recommendation with trade-offs; no automatic decision to replace the production engine.
Implement the isolated prototype described in this document. First read the repository instructions, the current reader API and the translation/annotation integrations. Inspect the candidate's actual APIs and license, pin its revision, then choose the smallest development harness that exercises both its EPUB parser and renderer. Keep the production engine intact and avoid unrelated refactors.
Demonstrate local reading, word interaction, inline translations, persisted highlights, durable resume after layout changes, and selected-chapter context extraction. Validate in a browser and Android's existing WebView path. Keep every persisted experiment record separate from production records. Do not call production write APIs or add backend endpoints for this spike.
Deliver the harness, focused checks, exact run instructions and a result matrix. Distinguish real translation calls from fixtures, automated checks from manual inspection, and tested clients from unavailable ones. Finish with a recommendation and the minimum next slice. Do not commit, push or create a PR unless separately authorized.
- Dependency/patch compatibility and license: verify the exact selected revision.
- Android runtime available? Otherwise its gate stays not run.
- Existing positions/highlights compatible? Record evidence; design migration separately.
ADR-029 Decision 6, run by an agent on emulators; the physical device is the owner's. Dev build of main @
5d48e2e5 (debug JS from Metro, so app-process memory is not a production number), dev route
textstack://dev/epub-v2. Emulator A: AVD Pixel_7_Pro, Android 17 (API 37), Android System WebView
145.0.7632.218. Emulator B: new AVD TS_API28, system-images;android-28;google_apis;arm64-v8a, WebView
66.0.3359.158. Open timings come from a throwaway host patch that logs [v2] PROBE openUrl|opened|relocate with
Date.now() (not committed); memory is adb shell dumpsys meminfo <pid> TOTAL PSS.
| Gate | Result | Numbers | Steps | Date | Who |
|---|---|---|---|---|---|
| Physical Android device | not run | — | — | — | owner (device, pending) |
| Book ≥ 80 MB | pass | 87,278,407 B, 117 sections, plates render. openUrl→opened 7.01 s (import), 7.65 s (re-import, same bytes adopted), 6.37 s (cold relaunch, retained copy); opened→restored 0.07–0.29 s. Renderer PSS 63.6 MB idle → 184–192 MB after open → 108–116 MB reading. Relaunch: position: "restored", fraction 0.13186 → 0.13186 |
below | 2026-10-30 | agent (emulator A) |
| ENOSPC during import | partial | 4 failure points made real. No library entry without its file and staging/ clean every time; a failed library.json write leaves an orphan books/<sha256>.epub and 0 B .tmp files that survive relaunch (findings 1–2, both fixed since in hostStorage.ts) |
below | 2026-10-30 | agent (emulator A) |
| Oldest supported System WebView | fail on WebView 66; recommendation | SyntaxError: Unexpected token ? before ready; the host waits forever. The committed page's real floor is Chrome 117 (Object.groupBy), not chrome90 |
below | 2026-10-30 | agent (emulator B + desktop Chromium) |
file:// security review |
written; recommendation, owner decides | one barrier (the nonce); behind it, the app's whole data dir is readable and the bridge can carry it out | below | 2026-10-30 | agent |
| Hostile book asserts, mutations caught | pass | R3 in CI: check:v2-reader-sandbox re-run, 30/30 blocked, 9/9 mutations caught. Re-run on the emulator through the dev route: 30/30 blocked; positive control (CSP bypassed) 21/30 escape |
below | 2026-10-30 | agent (desktop + emulator A) |
- Book. Project Gutenberg eBook #28885, Alice's Adventures in Wonderland, illustrated by Arthur Rackham
(OPF
dc:rights: "Public domain in the USA"; Carroll d. 1898, Rackham d. 1939; redistributable under the Project Gutenberg licence), 1,475,456 B, 15 sections with its own illustrations. Padded byapps/mobile/src/lib/v2reader/bigbook.mjswith 102 generated PNG plates (1200×1600 RGB: diagonal bands plus 6 noisy bits per channel so they do not compress away), each a manifest item with an<img>in its own spine section → 87,278,407 B (sha256314d6bff…77739f78for R4's file; zip timestamps change the hash per run). Plate 1 rendered as section 4/117 (screenshot checked); the cover and the book's own plates render. R4's run had a PRNG overflow whose short noise period made a plate ~0.85 MB compressed; the committed tool fixes the PRNG and keeps that shape on purpose withNOISE_ROWS = 8(fresh noise every 8th row), so MB=88 again gives ~100 plates of ~0.85 MB (on a 1-section book: 110 plates, 93,056,601 B) rather than 17 of ~5.5 MB. - Steps.
node apps/mobile/src/lib/v2reader/bigbook.mjs pg28885.epub apps/mobile/src/lib/v2reader/fixtures/alice-80mb.epub→adb push … /sdcard/Download/→ dev route → Pick EPUB → Downloads → the book, withadb logcat -v epoch -s ReactNativeJS:*. Read to section 5 (storedstore/<sha256>.jsonchecked withadb shell run-as app.textstack.mobile cat …),am force-stop, relaunch, tap the library row:restored {"found":true,"position":"restored"}, thenrelocateat the same fraction. - Observations. The open grows with the file: the page hashes the bytes in pure JS (
crypto.subtleis missing at this origin, seepage.mjs); the same SHA-256 takes 2.1 s for this file in Node on the host Mac. The open also needs roughly the book's size in free disk: Chromium spills the 87 MBFileblob to disk, and with ~9 MB free the open fails withNotReadableError(ENOSPC point B).
/data (16 GB) filled with adb shell fallocate -l <n> /data/local/tmp/fill*; a fallocate larger than the free
space keeps what it got, so it fills to 0. Android fights back: on low storage installd purges app caches (logcat
Failed to free up 1048576000 on /data), so the fill was re-applied after each purge. State read with
adb shell run-as app.textstack.mobile ls -la files/v2 files/v2/staging files/v2/books files/v2/store cache/DocumentPicker.
| Failure point | How | Reported | State left |
|---|---|---|---|
A. Picker copy, before our move into staging/ |
15 MB free, pick the 87 MB book (again: 1.4 MB free, 1.4 MB book) | screen: pick: Error: java.io.IOException: sendfile failed: ENOSPC |
staging/ empty, library unchanged. expo-document-picker's partial copy stays in cache/DocumentPicker/<uuid>.epub (15,679,488 B; 1,425,408 B), also after relaunch |
| B. Page open (blob spill) | ~96 MB free after the system purge: the copy lands, ~9 MB left | page error: open: NotReadableError … → open-failed |
staged file dropped, no entry. Pass |
C. library.json write, retained copy already moved |
new book (330,622 B); /data filled to 0 the moment the host logged openUrl (an on-device logcat -d poll, then fallocate) |
log [v2] opened failed: … FileSystemFile.write … ENOSPC; the book stays open and readable; every page turn then shows store: … ENOSPC, which replaces the opened error on screen |
books/41746405….epub retained with no library entry; library.json (946 B, 3 entries) intact; library.json.tmp 0 B; store/41746405….json.tmp 0 B; staging/ empty |
D. store write while reading |
/data filled to 0 after a good import |
store: … ENOSPC on every relocate |
no store/<sha256>.json for that book, a 0 B .tmp; once space came back the next save landed (406 B) |
Next launch (space freed, am force-stop, relaunch): deviceFiles() does wipe staging/ (recreated empty).
The orphan books/41746405….epub, library.json.tmp and store/…json.tmp (both 0 B) remain; the library lists 3
books and the orphan is invisible and counted nowhere. Reading is unharmed: readRecovering prefers the target, and
an empty .tmp reads as no entry.
Also seen: retained copies are owned by group u0_a231_cache (moved, not copied, out of the picker's cache
copy), and installd counted them as this app's cache (used=112750592 quota=125194981 with ~20 MB of real cache),
which makes the app a first purge target.
- Emulator B (WebView 66). Dev APK pulled from emulator A and installed (
minSdk 24). The dev route renders its buttons;adb logcat chromium:*shows"Uncaught SyntaxError: Unexpected token ?", source: file:///data/user/0/app.textstack.mobile/files/ (28)(optional chaining or??, Chrome 80 syntax) before anylogmessage. Noready, so the host never callsopenUrl: a picked book sits instaging/1793400532565.epubunder "Pick an EPUB", with no error, until the next launch wipes it (finding 3). - Lower target. A local copy of
build.mjswith only the target changed:chrome69builds (260,142 chars, +23,476 = +9.9 % overBUDGET236,666),chrome61too (264,301). esbuild lowers the syntax (no private-method calls left atchrome69) but does not polyfill APIs, and the pinned foliate callsObject.groupBy/Map.groupBy(Chrome 117),findLastIndex(97),.at()(92),replaceAll(85),Object.fromEntries(73). - API floor of the committed page (desktop Chromium, page as
file://, one API deleted by an init script, open PG #28885): nothing deleted →opened, relocates;Object.groupBy→open-failed: Object.groupBy is not a function;Map.groupBy→open-failed;.at→opened, never relocates (nothing shown);findLastIndex,replaceAll→ opens. The page needs Chrome ≥ 117 today, whatever the esbuild target;chrome90is only its syntax floor. - Options. (1) An "update Android System WebView" screen gated on the WebView's Chrome major ≥ 117: one number to
keep, no bundle cost; covers devices with Play (per Chromium's support announcements WebView reaches 119 on
Android 7 and 138 on Android 8–9, both above 117 — verify when building it), not devices without Play. Detection
must not need the page to boot: an ES5
injectedJavaScriptBeforeContentLoadedpostingnavigator.userAgent, or areadydeadline. (2) Lower target plus hand-written polyfills: ~+10 % bundle before the polyfills, a list that a foliate bump can outgrow silently (the pinned foliate already needs 117 while ADR-029 said 90, and nothing noticed), and stock Android 7–8 WebViews (Chrome 5x) stay out anyway. Recommended: (1), the owner's to confirm.
What a hostile EPUB reaches with the CSP intact: nothing outside its zip. Script: blocked (nonce). Network:
blocked (default-src 'none', every fetch directive blob:/data:). Sibling files: blocked for img, link and
iframe, and no script runs to use connect-src file:. Re-run of R3's tshostile.epub (built by fixtures.mjs
with siblings = files/v2/books) on the Android System WebView of emulator A, through the real host (picker →
staging/ → openUrl), attached over CDP (adb forward tcp:9222 localabstract:webview_devtools_remote_<pid>),
sibling secrets in files/v2/books/, a logging server on 127.0.0.1:9876 via adb reverse, every section visited
and every data-ts-click clicked: 30/30 blocked. link-tap reached the host as openLink and the host opened
Chrome, so the clicks reached the book. Positive control, the CSP disabled with CDP Page.setBypassCSP: 21/30
escape — all 7 script vectors (their marks land in top.__pwned: book frames share the page's window and its
ReactNativeWebView bridge), file-sibling-xhr, sibling img/css, <base> and 10 request vectors carrying the
WebView's user agent. Still blocked with the CSP off: iframe, file-sibling-iframe, object, embed (a second
barrier on Android, not identified), both meta-refresh rows (the page's strip), javascript-url, form, link-tap.
What one script in the page's origin reaches (emulator A, real CSP, XHR from the page): files/v2/library.json
and every retained book; directory listings of files/, files/SQLite/, databases/, shared_prefs/, cache/;
the bytes of databases/RKStorage (AsyncStorage) and, by name, the offline SQLite files/SQLite/textstack-offline
(catalogue and upload chapters); any world-readable file (/data/local/tmp/secret.json). Auth tokens live in
SecureStore (Keystore-encrypted), so ciphertext only. The CSP stops the network but not the bridge: openLink with
https://…?<data> makes the host open a browser on it, and store writes any JSON under a 64-hex key. That is the
whole cost of a CSP regression (the 9 mutations: R3's table), a leaked nonce or a Chromium CSP bug.
What the flags add. allowFileAccess lets the WebView load file: at all; allowFileAccessFromFileURLs lets a
file:// page XHR any other file — it is what turns connect-src file: into "the app's data dir";
allowUniversalAccessFromFileURLs=false only keeps http(s) XHR out, already the CSP's job;
onShouldStartLoadWithRequest (only about:/blob:/data:) keeps the top frame, which holds the bridge, from
navigating. R3's desktop check cannot see this half (the flags, content:, the bridge); the CSP half now has its
on-device run above.
The three options of ADR-029 Decision 4.
- Keep
file://+connect-src file:. Works and is the fastest measured (6.4–7.7 s for 87 MB). One barrier: the nonce. - Narrow
connect-src file:to the one file. Not expressible. A CSP host-source needs a host; Chromium rejectsconnect-src file:///…/book.epubandfile:///…/books/as "an invalid source", and the directive then blocks the book too (desktop Chromium, measured;'self'at this origin behaves asfile:). Forfilethe only choices are all offile:or none. - Hand the bytes in another way. Over the bridge (base64): the prototype measured 35.8 MB in 27.9 s against 4.7 s
by XHR; linearly ~68 s for this 87 MB book against 6.4–7.7 s, with several transient copies of the book in two JS
heaps beside a renderer already at ~190 MB — not viable. Natively: an intercepted origin (Android's
shouldInterceptRequest, e.g.WebViewAssetLoaderonhttps://appassets.androidplatform.net/) serving onlybooks/<sha256>.epub, a non-file:base URL and bothallowFileAccess*flags off; the CSP can then path-restrictconnect-srcto that one https URL. The same streamed read as today, but native code inreact-native-webview(it has no JS API for interception: a patch or a config plugin) and a changedopenUrl. Not measured.
R4 recommendation: option 3 in its native form before any non-dev route; option 1 stays accepted for the dev route until then. The owner decides (ADR-029 Decision 4).
- Orphan retained copy (medium):
addOpenedmovesstaging/x→books/<sha256>.epub, then thelibrary.jsonwrite fails → the copy stays with no entry, invisible, uncounted and never cleaned (point C). Fixed in this PR (hostStorage.ts): an error after this call's move removes that copy and rethrows; an adopted copy stays, and so does any copy the library lists asbooks()reads it (e.g. a completelibrary.json.tmpa failed move kept). A kill between move and entry still leaves a copy the next import adopts (by design); no start-up sweep here: sweepingbooks/,store/and.tmpfileslibrary.jsondoes not list is #1070's (R6 storage accounting needs the samelist()), not R4a's. A failed import also clears the screen's staged open. TestsaddOpened_LibraryWriteFailsAfterTheMove_RemovesTheRetainedCopyAndRethrows,addOpened_LibraryWriteFailsForAnAdoptedCopy_KeepsTheCopy,addOpened_FirstImportLibraryMoveFails_KeepsTheCopyTheRecoveredEntryNames,addOpened_LibraryMoveDeletesTheTargetThenFails_KeepsTheCopyTheRecoveredEntryNames,addOpened_FirstFailsSecondAdoptsSameBytes_OneCopyOneEntry. - Empty
.tmpleft behind (low):writeAtomicallyleaves its 0 B.tmpwhen the write fails. Fixed in this PR (hostStorage.ts): a failed write removes its tmp best-effort and rethrows the write's error (a failed move keeps the tmp: it may be the one complete copy). TestswriteAtomically_WriteFails_RemovesTheTmpAndRethrows,writeAtomically_WriteAndTmpRemovalBothFail_TheWriteErrorPropagates,writeAtomically_MoveFails_KeepsTheCompleteTmpAndTheTarget. - No deadline on
ready/opened(medium): on WebView 66 the screen says "Pick an EPUB" forever. - Retained copies carry the cache group (low): moved out of the picker's cache copy, so Android accounts them as
app cache. Copying from the picked URI straight into
staging/also removes point A's partial file. - Partial picker copy on ENOSPC (low):
cache/DocumentPicker/<uuid>.epub, see 4. - Error UI (low): one error line, so the
openedENOSPC is replaced by the nextstoreerror; after anopen-failedthe page has already closed the previous book while the heading still names it; a full disk reads as a rawNotReadableError.