Building ScanCam: On-Device Document OCR That Admits What It Can’t Do

Aug 2026 · 3 min read

scan

I shipped ScanCam — a fast, private document scanner for Android and iOS. The product loop is familiar on purpose: scan → crop → enhance → OCR → PDF → sign → share.

Familiar loops still hide hard product decisions. For ScanCam, the decision that mattered most was honesty about OCR.

The problem that isn’t glamorous

Scanner apps compete on filters and “AI.” Users compete with reality: receipts, contracts, IDs, whiteboard photos, and the occasional handwritten note.

On-device OCR (Google ML Kit) is excellent for printed text. It is weak on handwriting. Shipping that as undifferentiated “AI text recognition” trains users to distrust the whole app the first time a note fails.

So the constraint became:

Can we ship competitive OCR without pretending the model can read everything?

Provider-agnostic, on-device by default

OCR sits behind an OcrService interface with an isOnDevice flag. Callers ask for recognition; they don’t hard-code ML Kit. Today’s implementation is MlKitOcrService: images stay on the device; recognition never uploads the page.

Models are per-script, not per-language. The UI exposes Latin, Chinese, Devanagari, Japanese, and Korean. Inside a script, language detection is automatic. First use of a non-Latin script may download that model once via Play services — document images still don’t leave the phone. That caveat belongs in the privacy policy and the UI, not in a footnote after a bad review.

The code comment that sets the product tone:

ML Kit’s on-device recognizer targets printed text. Handwriting accuracy is poor and we say so in the UI rather than pretending otherwise.

That sentence is the feature.

Local-first is a table, not a slogan

Capability

Where

Network?

Capture, edge detect, warp, filters

Device (Dart isolates)

No

OCR, QR decode

Device (ML Kit)

No¹

PDF create/merge/split/compress/encrypt

Device

No

Signatures

Device, encrypted

No

Backup ZIP

Device; leaves only via user share

User-initiated

Translation / cloud sync

Not configured

Opt-in by design later

¹ Non-Latin model fetch caveat above.

Storage is app-private. No broad storage permissions. Camera is asked in context; denial falls back to gallery import. No location, contacts, or microphone.

Signatures use AES-256-GCM per file. Keys live in Android Keystore / iOS Keychain via secure storage — never exported, never in backups. Erase-all destroys ciphertext and the key. PDF passwords are transient unless the user sets a per-document export password.

Monetization without soft-reset cheats

Free tiers meter OCR pages, advanced PDF ops, signature exports, and similar features. Usage counters live in SQLite, not shared preferences — clearing prefs must not reset allowances. Consent matters: signature flatten confirms consequences; exports disclose watermark/quality limits before work starts.

Privacy is not the premium upsell. The core scan path stays local.

What I shipped

Flutter + Riverpod. Original blue design. Recycle bin with delayed purge. Full backup export. Profile erase with double confirm. Remotely configurable monetization for store experiments without shipping a new binary for every knob.

Store links:

What I’d still harden

Cloud translation/sync, if ever enabled, must force disclosure before any provider is wired. Syncfusion licensing needs a commercial eligibility check before scale. And handwriting will always be a temptation to oversell — keep the UI copy cruelly accurate.

Bet: trust compounds when the app refuses to claim magic.

If you’re shipping on-device ML: put the limitation in the primary UI, not the FAQ.

  • flutter
  • ocr
  • ml-kit
  • privacy
  • pdf
  • document-scanner
  • on-device