DEV Community

Muhammed Said Çakır
Muhammed Said Çakır

Posted on Fully Autonomous

Building an offline snag-list app with Expo, Skia and pdf-lib

A snag list (a punch list in the US) is the walk-round at the end of a job: you photograph every defect, mark it up, note where it is, and send the builder one numbered report. Electricians do it for clients, site managers do it at handover, and new-home buyers in the UK do it before the builder's warranty clock runs out.

The apps for this split into two camps. Some are team platforms with an account, a cloud project and a monthly price per user. Most of the others are free to install and charge for the one thing you actually need, the PDF. I wanted a third kind: pay once, no account, and it works in a basement with no signal. That became PunchSheet. This post is about the parts that were harder than they look.

No network, enforced

"Works offline" is easy to say and easy to break later, so the rule is mechanical:

  • The Android release build has no INTERNET permission. A config plugin removes it only from the release source set, so debug builds can still reach Metro.
  • The build script dumps the APK's permissions and compares them with an exact list. An extra permission fails the build.
  • A repository rule rejects fetch, XMLHttpRequest and WebSocket in the app's source.

Projects live in SQLite (expo-sqlite), photos in the app's own files. There is nothing to sign in to and nothing to sync, which also makes the privacy policy short: we receive nothing from the app.

Photos stay original; markup is data

Each snag can hold several photos. The original file is never changed. Markup (arrows, circles, rectangles, lines, freehand, text) is stored as vector shapes and drawn with React Native Skia.

The PDF never uses the original. When a photo is added, the app writes a report copy: long edge 1,600 px, JPEG quality 0.8, with the markup flattened onto it through Skia. The report is then built from those copies.

That decision came from a spike on a real phone before any screens existed. Building a 100-photo report from 12 MP test images, with the shrunken JPEGs held in memory, peaked at 1.1 GB. Streaming them one by one brought the peak down to 764 MB, still too much for a mid-range phone. Doing the expensive decode once, at add time, is what made the report fast.

A 15 MB budget for the PDF

A report that email refuses is useless on site, so the PDF has a budget: 15,000,000 bytes, the decimal reading of common 15 MB limits.

The PDF is assembled on the phone with pdf-lib, in plain JS. Each photo goes through a ladder of encodings, from 1,600 px down to 1,200 px and quality 0.7 down to 0.5. Walking the ladder photo by photo meant re-encoding the same image many times. Instead, the app predicts the starting rung from size ratios measured on real photos, and the budget is shared across the whole report.

With 100 downloaded test photos, the prediction cut the build from 32 to 7 seconds and from 596 encodes to 89. On a Nothing Phone (2), a project with 50 snags and 100 camera photos now gives a 26-page report of 14.08 MB in about 11 seconds.

Pure core, tested in Node

Everything that isn't UI is plain TypeScript: numbering, the report model, the size budget, the backup format, EXIF orientation, markup geometry. It runs under node --test --experimental-strip-types.

The storage layer talks to a small Db interface. On the phone that is expo-sqlite; in tests it is Node's built-in node:sqlite, so the repository tests use a real SQLite database without a device.

Backups you can carry

With no cloud, the backup is the user's safety net. A project exports to one .punchsheet file (a zip made with fflate) with every photo and all markup, and imports on another phone, Android or iPhone.

The first import read the file in one go. With a 211 MB backup, File.bytes() hit the Java heap's 256 MB limit and threw OutOfMemoryError. Import now streams the file in 1 MB chunks and keeps one photo in memory at a time.

Small things that cost an evening each

  • R8 renames exception classes. Expo derives an error code from the Kotlin class name, and in a minified release build that name changes. Cancelling the folder picker started showing "could not be saved". The app now also checks the message ("cancelled by the user").
  • Android adds ".zip". Writing a backup through the storage access framework with the application/zip type made Android rename it. The file is written as application/octet-stream.
  • iOS merges labelled Pressables. A dialog whose backdrop was a labelled Pressable wrapping the card became a single "Cancel" element in VoiceOver, and the Delete button was unreachable. The backdrop is now a sibling of the card.
  • Play's own protection works offline. Google Play adds a licence check to protected apps. On a phone in airplane mode, with the Play Store stopped, it still passed on a cold start.

Where it is

PunchSheet costs $9.99 once (each store sets its local price): no subscription, no in-app purchases, no ads. The UI is English, and the app calls them snags, punch items or defects, as you prefer.

If you've built something offline-first with Expo, I'd like to hear what broke for you.

Top comments (0)