Skip to content

ADR-0037: Export carries the captured-location snapshot (format v3)

Context

The portable CSV/JSON export (ExportCodec + ExportRepository) projected a transaction onto occurred_at, merchant, amount, currency, direction, status, category, account, source, note, parse_source, raw_json, items only. The captured-location columns (latitude, longitude, accuracyMeters, locationProvider, locationCapturedAt, placeName, placeId) added in schema v6 (ADR-0021) and populated by opt-in capture (ADR-0026/0034) were never serialised, so a user backup silently dropped location and a restore lost it. The ADR-0017 in-editor JSON preview diverged from a full export for the same reason.

Location is user data captured opt-in and stored encrypted on device. Export is a user-initiated SAF save with no network, so including it keeps the backup complete while staying consistent with the offline-first posture (ADR-0015).

Decision

The portable export row is the single complete projection of a transaction; it carries the captured-location snapshot alongside the existing fields.

  • ExportRow gains seven nullable fields with null defaults: latitude, longitude, accuracyMeters, locationProvider, locationCapturedAt, placeName, placeId.
  • CSV: the seven columns are appended after items, in the order above. No existing column index shifts, so older CSVs stay readable by header name. Absent values are written as empty cells.
  • JSON: non-null values are written; null values are omitted (encodeDefaults = false on the codec). ignoreUnknownKeys = true plus nullable defaults means v1/v2 files without location parse unchanged.
  • ExportFile.version bumps 2 -> 3 to signal the richer schema. Import does not gate on version, so v1/v2 files still import and simply have no location.
  • ExportRepository maps the seven columns in both directions, and TransactionDraft.toExportRow maps the draft's location fields so the ADR-0017 preview matches a full export.
  • The review-queue developer export (UnmatchedJsonCodec / UnmatchedExportCodec) is a separate surface and keeps its current shape.
  • Attachments stay out of the export (ADR-0027). Reverse-geocoded placeName/placeId are still placeholders (ADR-0021) and are serialised only when already present.

Consequences

  • A CSV/JSON backup is complete and re-importable, including the optional location snapshot; note round-trips with explicit regression coverage.
  • One export format serves both the bulk save dialog and the in-editor preview.
  • No Room schema change, no migration — codec/repository only.
  • Because a user-run export writes location into the exported file, the privacy statements must disclose that; location is still never transmitted (ADR-0015).

Rejected alternatives

  • Separate location file / join key — adds a second artifact to a backup and breaks the "one portable file" promise for no benefit at this size.
  • Gate import on version == 3 — would reject every existing v1/v2 backup; additive nullable fields need no gate.
  • Insert location columns mid-header (next to raw_json) — shifts existing column indices and breaks header-indexed consumers.
  • Include location in the developer unmatched export now — different payload purpose; left as a follow-up.

Amendment (2026-10-19): the export is read as one snapshot

Every export read (visible transactions, their items, category and account metadata) happens through one ExportDao.snapshot() @Transaction; children are bulk-loaded with a single transactionId IN (...) query instead of one query per transaction, and mapping/serialization runs after the transaction closes. An export is therefore self-consistent under concurrent edits, and no file picker or output-stream work happens inside the database transaction.