Skip to content

ADR-0055: Import rejects invalid backups with a recoverable error

Context

SettingsViewModel.importJson launched an IO coroutine that let ExportCodec.parseJson's SerializationException escape. Picking a malformed file ({invalid) crashed the process with a fatal kotlinx.serialization.json.JsonDecodingException; the user saw no message. The file-provider read also ran on the main thread, so a read failure could crash before the coroutine even started. A crash is not a recoverable error, and reporting a rejected file as "Imported 0 transactions" would be a lie.

Decision

Import validates the payload before any write and reports a typed outcome; the UI turns it into a localized, accessible message.

  • ExportCodec.parseJson raises ExportFormatException (carrying an ImportError) for empty/blank payloads, malformed or wrong-shape JSON, and versions outside the supported 1..CURRENT_VERSION range. Older supported versions still parse (ADR-0054).
  • ExportRepository.importJson returns ImportResult: Imported(inserted) or Rejected(error). It catches only ExportFormatException; unexpected persistence failures propagate rather than being converted into a false success.
  • The SettingsViewModel.importJson overload reads the picked Uri on Dispatchers.IO, closes the stream, maps read failures to ImportError.Unreadable, rethrows CancellationException, and delivers the result back on the main thread.
  • The Data screen renders the outcome as a live-region message: an inserted count on success, a specific error string otherwise. Cancelling the picker (null Uri) is a silent no-op.

Consequences

  • The exact {invalid file shows "Import failed: this isn't a valid AutoBudget backup"; the app stays alive and the ledger is untouched.
  • Empty, truncated, wrong-shape and unreadable files are handled the same way.
  • A backup from a newer app version is rejected explicitly instead of being imported as an empty success.
  • Valid retries report the real inserted count; a cancelled picker has no side effects.

Rejected alternatives

  • Catch Exception in the repository and return Imported(0) — hides real failures as false success.
  • Report only a generic "Import failed" — loses the actionable empty/malformed/unsupported/unreadable distinction.
  • Keep reading the document on the main thread — leaves a read-failure crash path open.

Amendment (2026-10-19): CSV text cells are neutralized

CSV quoting delimits fields but does not stop a spreadsheet from evaluating a cell that starts with a formula marker. Every untrusted TEXT column is prefixed with a single ASCII apostrophe when its first significant character (after whitespace, Unicode format characters and control characters) is =, +, -, @ or a full-width variant. Numeric columns (amount, coordinates, timestamps) are exempt, so a negative amount stays numeric; JSON exports are unchanged. The apostrophe is part of the stored cell text, and the defence is documented with its limits (importer version/locale, save-and-reopen) rather than as a guarantee.