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.TakePicturewithFileProvider, pending files under the app cache directory). NoCAMERApermission is declared — the system camera owns it, keeping Play's sensitive-permission surface unchanged. A<queries>intent filter forIMAGE_CAPTUREcovers 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 viaMatrix.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.stagePhotointo 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
ocrTextcolumns, 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=falsekeeps 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
CAMERApermission 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.