Skip to content

ADR-0033: Receipt photo capture with auto-detected perspective crop

Context

Users photograph receipts at the point of payment; angled shots need deskewing before a photo is useful as an attachment (and for any future OCR/AI prefill). The app is offline-first with no third-party SDKs and no INTERNET permission (ADR-0015); docs/PLAY_POLICY.md leans on that posture. Attachments already exist (ADR-0027) with draft-then-save staging.

Decision

  • Capture via the system camera app (ActivityResultContracts.TakePicture with FileProvider, pending files under the app cache directory). No CAMERA permission is declared — the system camera owns it, keeping Play's sensitive-permission surface unchanged. A <queries> intent filter for IMAGE_CAPTURE covers Android 11+ package visibility.
  • Hand-rolled perspective crop in the new pure-Kotlin receipt/ package:
  • QuadCropGeometry — normalized quad + quarter-turns; convexity, degeneracy, clamping, output sizing. No Android imports.
  • QuadDetector — edge detection over a ≈384 px luminance buffer: separable blur → Otsu luminance segmentation (bright page vs darker background, inverted-polarity retry capped at 90% frame coverage so the backdrop never wins) → largest connected blob → dominant-corner extraction (extremes of x+y / x−y, ordered clockwise from top-left). Returns null on low confidence; the cropper then starts from an inset full-frame quad. Detection failure never fails a capture.
  • ReceiptPhotoProcessor — Android glue: bounds-checked downsampling (detection ≤384 px, output ≤2048 px), luminance bridge, and warp via Matrix.setPolyToPoly(…, pointCount = 4) (true perspective), encoded as JPEG q85. The deskewed output is deliberately OCR-grade.
  • Staging reuses ADR-0027: confirmed crops flow through AttachmentRepository.stagePhoto into the normal staged-attachment list; no schema change (receipts are ordinary IMAGE attachments).
  • Single path accessor: AttachmentRepository.fileForStored(storedPath) resolves attachment files; preview header, cropper reload, and future OCR never build paths themselves.
  • Forward-compatibility contract (no speculative code): receipts are enumerable as typed IMAGE attachments; bytes resolve through one accessor; processed images are OCR-grade; editor setters remain the prefill seam. No receipt/origin flags, no ocrText columns, and no stub analyzer interface until the OCR issue defines what it stores — analysis state would ship as a normal schema migration.
  • The editor was also restructured once into a sectioned layout (CollapsibleSectionCard) so the receipt UI lands in a skeleton that does not require endless scrolling: core fields stay visible; category/status, items, note, location, and attachments collapse independently with content-aware defaults and collapsed-value summaries.

Consequences

  • Zero new dependencies; APK growth limited to ~4 small classes.
  • uses-feature camera.any required=false keeps the app installable on camera-less devices; "Take photo" hides behind a Snackbar when no camera app responds.
  • Only the deskewed image is kept — the un-cropped original lives in cache until confirm/cancel and is deleted either way.
  • Detection is best-effort heuristics: low-contrast scenes fall back to the manual quad rather than blocking capture.

Rejected alternatives

  • In-app CameraX capture screen — adds CameraX deps plus a CAMERA permission declaration (Play sensitive-permission surface + policy doc changes) for framing control we do not need.
  • ML Kit Document Scanner — GMS-delivered UI (capture + auto-crop) but a runtime Play-services dependency and a third-party SDK claim violation.
  • OpenCV for detection — tens of MB of native libraries per ABI for a job four small classes handle.
  • Crop libraries (uCrop / android-image-cropper) — merged-manifest activities and the "no third-party SDKs" posture break.
  • Coil/Glide for previews — same posture argument; attachments are few, local, and trivially decoded with sampling.
  • Keeping the un-cropped original alongside the deskewed copy — doubles storage for marginal re-processing value; the cache file is deleted after staging.