Pocky is offline-first by design — no backend, no account, no network requirement. That commitment extended to receipt scanning. I didn't want to upload images to a cloud OCR service when Apple ships one on every iPhone, runs it entirely on-device, and it's faster than any network round trip.
The result is a custom Expo native module that presents VNDocumentCameraViewController, processes the scan with VNRecognizeTextRequest, and returns structured text to the React Native layer as a resolved Promise.
Setting up expo-modules-core
The right way to write native modules in Expo's ecosystem is through expo-modules-core. It handles the bridge boilerplate and gives you a typed Swift API.
npx create-expo-module pocky-ocr --local
This scaffolds the module structure inside your app's modules/ directory. You end up with a Swift file that extends Module:
public class PockyOCRModule: Module {
public func definition() -> ModuleDefinition {
Name("PockyOCR")
AsyncFunction("scanReceipt") { (promise: Promise) in
self.startScan(promise: promise)
}
}
}
Presenting the camera controller
VNDocumentCameraViewController must be presented on the main thread from your app's key window. The tricky part is that the AsyncFunction callback in the module runs on a background thread. You have to dispatch to main explicitly and hold a strong reference to the controller.
The strong reference is non-obvious. If you create the controller inside a local variable and present it, ARC will deallocate it immediately after the function returns — the camera flashes and disappears before it's usable.
// Store this at the class level, NOT as a local variable
private var cameraController: VNDocumentCameraViewController?
private var pendingPromise: Promise?
func startScan(promise: Promise) {
self.pendingPromise = promise
DispatchQueue.main.async {
let controller = VNDocumentCameraViewController()
controller.delegate = self
self.cameraController = controller // retain it
UIApplication.shared.keyWindow?.rootViewController?
.present(controller, animated: true)
}
}
Processing the scan result
The delegate fires documentCameraViewController(_:didFinishWith:) with a VNDocumentCameraScan. Each page is a UIImage. You pass each image to a VNRecognizeTextRequest and collect the results:
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFinishWith scan: VNDocumentCameraScan
) {
controller.dismiss(animated: true)
var allText: [String] = []
let request = VNRecognizeTextRequest { req, _ in
guard let observations = req.results as? [VNRecognizedTextObservation] else { return }
for obs in observations {
if let top = obs.topCandidates(1).first {
allText.append(top.string)
}
}
}
request.recognitionLevel = .accurate
for i in 0..<scan.pageCount {
let image = scan.imageOfPage(at: i)
guard let cgImage = image.cgImage else { continue }
let handler = VNImageRequestHandler(cgImage: cgImage)
try? handler.perform([request])
}
self.pendingPromise?.resolve(allText.joined(separator: "
"))
self.pendingPromise = nil
self.cameraController = nil
}
Release both references after resolving. Otherwise you're holding the camera controller in memory for the lifetime of the module.
The JS API
From the React Native side, it's just a Promise:
import { NativeModules } from "react-native";
const { PockyOCR } = NativeModules;
const text = await PockyOCR.scanReceipt();
// text is a newline-joined string of all recognized lines
For Pocky, the returned text goes through a lightweight parser that looks for price patterns (\d+\.\d{2}) and date strings to pre-fill the expense form. The parsing is imperfect, but it's fast and works without a network connection.
What I'd change
The module currently returns a flat string. I'd change it to return structured observations — each line with its bounding box and confidence score — so the parser has more signal to work with. The VNRecognizedTextObservation has that data; I just flattened it too early.