An Android app that declares no INTERNET permission cannot download models. That is the whole problem, and it shaped every other decision in this post.
This is VaultPDF, an offline PDF editor (com.securepdfeditor.pro, v2.0.1). Scanned PDFs become searchable, and can also be rebuilt into editable documents. Everything below runs on device.
1. "No INTERNET permission" is an artifact property, not a promise
Most "offline" claims are behavioral: the app says it does not upload. Ours is structural. The manifest strips the permission at merge time:
<uses-permission android:name="android.permission.INTERNET" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
Every release bundle is checked with aapt2 dump permissions, and the release is blocked if INTERNET reappears through a transitive dependency. Users can verify the same thing without trusting us: Settings → Apps → VaultPDF → Permissions has no network entry at all.
That constraint turns OCR into a delivery problem before it is a recognition problem.
2. Getting 254 MB of models onto a device that never downloads
Cloud APIs, runtime language packs, and "download the model on first launch" are all dead on arrival. What is left is Play Asset Delivery, where Google Play performs the transfer and the app still has no network capability.
We ship exactly one install-time pack, ocr_models, about 254 MB:
| Asset | Size | Role |
|---|---|---|
layout.onnx |
214 MB | PP-DocLayoutV2 page layout analysis |
rec.onnx |
21.2 MB | PP-OCRv6 text recognition |
rec_korean.onnx |
13.5 MB | Korean recognition (see §8) |
det.onnx |
9.9 MB | text detection |
table.onnx |
7.8 MB | SLANet+ table structure |
Install-time means it is present at first launch, so there is no "OCR is still downloading" state and no first-run network dependency.
0-INTERNET does not mean the device never touches the network. It means the app itself has no network capability, and distribution is delegated to Play. That distinction matters, and it is the honest version of the claim.
3. Why not ML Kit
ML Kit answers "what text is in this image?" A PDF editor needs more:
- layout classes — body, title, table, figure, formula, header, footer;
- table structure — rows, columns, cells;
- reading order for multi-column pages;
- PDF object reconstruction — invisible text layers, editable text objects, table grids;
- CJK-safe search, copy, and editing.
ML Kit is not bad. It solves a smaller problem than the one a PDF editor has.
4. Searchable and editable are two different outputs
The recognition result is not the product. The product question is: how does the text get written back into the PDF?
Searchable mode (default). The original scan is untouched. Text objects are added with renderMode=3, so they are invisible but searchable and copyable. Same idea as Acrobat's "Searchable Image".
Editable mode. A new document is generated from visible text objects, table lines, and cell text. The original file is never mutated in place.
Our early failure was drawing visible OCR text on top of a page that still contained the scan, so both texts printed over each other. The fix was not coordinate tuning — it was semantic separation:
- searchable = original image + invisible text;
- editable = new document + visible reconstructed text;
- the two live as separate documents, and the user switches between them.
That also removes the worst failure mode of editable OCR: a bad rebuild can never destroy the original scan.
5. OCR changes what redaction has to mean
Before OCR, a scanned page is pixels. After OCR, it may also contain a hidden text layer.
Draw a black rectangle over the pixels and the text underneath is still searchable and copyable. Real redaction has to remove both the visible content and the text layer.
This is a test, not a slogan — the suite includes searchablePdfWriter_ocrTextIsRemovedByErase.
6. The CJK bug that taught us the most: Kangxi radical substitution
One of the nastiest bugs came from our custom libpdfium.so ToUnicode CMap behavior: some ideographs that share shapes with Kangxi radicals were stored as radical codepoints.
-
高 (U+9AD8)came back as⾼ (U+2FBC); -
白as⽩; -
力as⼒.
One root cause surfaced as three separate product bugs:
- copy and extraction returned radical codepoints;
- search failed, because the user typed the real ideograph while the PDF stored the radical form;
- editing failed, because
char → charcodemapping could not resolve.
The fix was a CjkNormalize layer: display/copy/extraction map radicals back to canonical ideographs, search checks both forms, and editing registers canonical aliases in the charcode map.
The lesson generalizes: the model can recognize text perfectly and PDF write-back can still corrupt its semantics.
7. The real-device OOM: 214 MB is not something you casually readBytes()
layout.onnx is 214 MB. Opening it from a normal file path worked in development. Through the real install-time PAD path it did not, because the early code did this:
assets.open("ocr-models/layout.onnx").readBytes()
That allocates ~214 MB in the JVM heap. On device:
Throwing OutOfMemoryError "Failed to allocate a 213964005 byte allocation"
model missing: layout.onnx
The UI symptom was misleading: OCR simply "found no text."
The fix: stream-extract to a cache file, then let ONNX Runtime mmap it by path. Copy with a 1 MB buffer, never materialize the model in the heap, extract once and reuse.
This only reproduced through the release path — AAB + bundletool --local-testing + real device + logcat. A debug build hid it completely.
8. Japanese and Korean
Japanese works through the default PP-OCRv6 dictionary; kana and shared Han characters are covered, and on-device tests recognize mixed-script lines correctly.
Korean needed its own path. The default dictionary has no Hangul, so we added a dedicated PP-OCRv5 Korean recognizer (13.5 MB) and route low-confidence suspected-Korean regions to it.
The general lesson: multilingual OCR is not "the model supports 100+ languages." Dictionaries, fallback routing, and confidence strategy all have to ship as well.
9. The tier we measured and then did not ship
We also built a much stronger high-accuracy tier: PaddleOCR-VL 1.6 Q4 running through llama.cpp/mtmd with Vulkan offload, via JNI. On a natural 150-page sample it scored 0.0499 mean text-edit, with English at 0.0368 and English/Chinese mixed at 0.0322 — at or near the public PaddleOCR-VL reference line. On a Xiaomi XRing O1 device, Vulkan offload cut one crop from ~34 s to ~20 s with byte-identical output.
It is not in the current release.
PaddleOCR-VL is about 1.1 GB, which cannot be an install-time pack. It has to be a fast-follow pack — and fast-follow extraction requires a foreground service. The asset-delivery library then declares FOREGROUND_SERVICE and FOREGROUND_SERVICE_DATA_SYNC, which means a Play foreground-service declaration and a justification video for an app whose entire pitch is an empty permission list.
We chose the empty permission list. The manifest now strips those permissions too, and ships only the install-time pack.
That is the real trade-off behind "zero permissions": it is not free, and this is what it cost us. The VLM path stays in the codebase and is measurable, but the current build does not carry it.
10. Results, stated plainly
Fast tier (the shipped one), official OmniDocBench text_block edit distance, full 1651-page set, lower is better:
text_edit: 0.263
latency: 1.3–1.9 s/page
That is not leaderboard SOTA, and we are not going to pretend otherwise. It is a fast, stable, fully offline default that handles everyday scanned documents.
The numbers people usually quote for this model family come from the large VLM tier — the one described in §9 that we did not ship.
11. Testing: 338 instrumentation tests
Validation was not "click a few pages." The Android test suite currently has 338 @Test cases, with OCR-relevant coverage including:
- searchable PDF writer with invisible text layer;
- replacement of a previous invisible OCR layer;
- reconstructed PDF writer with visible editable text;
- rotated pages;
- OCR text removed by erase/redaction;
- CJK font, editing, search, and page-geometry regressions.
We also used bundletool --local-testing to reproduce Play's delivery behavior locally — that is how the 214 MB OOM was caught.
Generating code is cheap now. These checks are what make it engineering.
12. What this cost, and what it bought
The hard part was never one model. It was the chain:
- delivering models without giving the app network capability;
- loading large models without OOM;
- writing results back without destroying the original;
- keeping search, copy, edit, and redaction on one semantic layer;
- surviving PDF font and ToUnicode traps;
- and deciding what to leave out when a feature and a principle collided.
The shipped shape is: fast on-device OCR by default, searchable and editable outputs, true redaction that also removes the OCR layer, and no INTERNET permission in the app.
You can verify the last one yourself in Settings.
VaultPDF is on Google Play: https://play.google.com/store/apps/details?id=com.securepdfeditor.pro
Top comments (0)