ADR-0077: Imported documents are read with a 16 MiB cap
- Status: Accepted
- Date: 2026-10-18
- Deciders: maintainers
- Related: ADR-0009, ADR-0027, ADR-0054, ADR-0055, ADR-0076
Context
SettingsViewModel.importJson read the whole picked document with
stream.bufferedReader(Charsets.UTF_8).readText() before decoding. Nothing
capped the size, and the document comes from an untrusted ContentProvider, so
a huge — or merely lying — provider could exhaust the heap before any
validation ran. The sibling attachment path already caps its read at 10 MB
(ADR-0027); import had no cap at all. ImportError (ADR-0076) distinguished
read, validation and save failures, but had no size class, and
InputStream.readBytes can size its first buffer from available(), so a
provider that over-reports its length worsens the allocation.
Decision
ExportCodec.MAX_IMPORT_BYTES = 16 * 1024 * 1024(16 MiB) is the cap.ExportRowcarries up to 22 fields (~400–600 B of JSON per row), so 16 MiB is roughly 25–30k transactions — about 8 transactions a day for ten years. Peak heap stays near 32 MB even holding the whole payload as a UTF-16String.ExportCodec.readImport(stream, limit = MAX_IMPORT_BYTES)copies in 64 KiB chunks and stops atlimit + 1bytes. One byte past the cap is enough to prove the payload is too large, so an oversize or lying provider is never drained. The provider's length metadata —available(),openAssetFileDescriptor().length— is never consulted.ImportError.TooLargeis added to the sealed hierarchy (ADR-0076). The Data screen shows a distinct localized message that names the limit.- Whole-payload limitation, stated explicitly:
ImportRead.Okis the full decoded document, not a streaming parser. This ADR bounds a read that already materialized the payload in memory; replacing the JSON decode with row-by-row streaming is a larger refactor acrossExportCodecandExportRepositoryand is deliberately out of scope. - Cancellation and failure semantics are unchanged: a
CancellationExceptionis rethrown,usecloses the stream on every exit path, and an oversize document performs no writes because it never reaches the repository.
Consequences
- A document above 16 MiB is rejected as
TooLargewithout a full read and without any ledger write; the rejection is distinct from unreadable, malformed, unsupported-version and invalid-payload. - Normal exports, far below the cap, round-trip unchanged.
- JVM tests cover the exact boundary, boundary + 1, an unknown-length stream, lying metadata, a mid-stream IO error, UTF-8 decoding and the default limit; the Android-bound ViewModel read path is covered by compilation and the instrumented read/ledger checks.
- No Room schema change and no new dependency;
ExportCodecstays pure Kotlin.
Rejected alternatives
- No cap / trust the provider's length — the heap-exhaustion this removes,
demonstrated by an
OutOfMemoryErrorfrom a stream whoseavailable()over-reports. - Reuse the attachment 10 MB cap — import rows are text-only and the export rationale above supports 16 MiB.
- Stream rows one at a time — a larger refactor that changes the decode and repository seams; out of scope, documented as a limitation instead.
- Silently truncate at the cap — would import a corrupt partial ledger.
Amendment (2026-10-19): export shares the import cap
ExportRepository.exportJson measures the serialized payload with
ExportCodec.utf8Size (exact UTF-8 bytes, surrogate-safe) and returns
JsonExportResult.TooLarge above MAX_IMPORT_BYTES; the Settings screen stages
only Ready, so an over-limit ledger never reaches the destination picker and
no un-restorable file is written. The comparison is inclusive (the bounded read
accepts exactly the cap). CSV is deliberately unguarded: import is JSON-only, so
a CSV has no restore limit to violate and stays a usable spreadsheet export.
Streaming or partitioned large backups remain out of scope.