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.